Compare commits

..

1 Commits

Author SHA1 Message Date
Tushar Vats
c95523c747 feat: export traces (#9991)
Some checks are pending
Release Drafter / update_release_draft (push) Waiting to run
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
* feat: added trace export

feat: added types for export

feat: added support for complex queries

fix: added correct open api spec

fix: updated unit tests

fix: type handling logic

fix: improve order by

feat: added integration tests

fix: address comments

* fix: address comments

* fix: removed nits

* fix: go fmt

* fix: rebased main and ran generate cmd

* fix: renamed method

* fix: address comments

* fix: lint error

* fix: lint error

* fix: ran yarn generate:api

* fix: address comments

* fix: address comments

* fix: typo

* fix: better names for functions

* fix: added unit tests, renamed file, added validation

* fix: update integration test

* fix: removed get method for export

* fix: yarn generate:api

* chore: yarn generate:api

* fix: rename file

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-23 21:46:56 +00:00
83 changed files with 4610 additions and 3095 deletions

View File

@@ -51,7 +51,6 @@ jobs:
- alerts
- ingestionkeys
- rootuser
- serviceaccount
sqlstore-provider:
- postgres
- sqlite

View File

@@ -17,5 +17,7 @@
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
},
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
}

View File

@@ -1860,6 +1860,8 @@ components:
type: object
ServiceaccounttypesPostableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
@@ -1868,6 +1870,7 @@ components:
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesServiceAccount:
@@ -1875,6 +1878,9 @@ components:
createdAt:
format: date-time
type: string
deletedAt:
format: date-time
type: string
email:
type: string
id:
@@ -1899,6 +1905,7 @@ components:
- roles
- status
- orgId
- deletedAt
type: object
ServiceaccounttypesUpdatableFactorAPIKey:
properties:
@@ -1913,6 +1920,8 @@ components:
type: object
ServiceaccounttypesUpdatableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
@@ -1921,6 +1930,7 @@ components:
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesUpdatableServiceAccountStatus:
@@ -2053,6 +2063,43 @@ components:
required:
- id
type: object
TypesGettableAPIKey:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
createdByUser:
$ref: '#/components/schemas/TypesUser'
expiresAt:
format: int64
type: integer
id:
type: string
lastUsed:
format: int64
type: integer
name:
type: string
revoked:
type: boolean
role:
type: string
token:
type: string
updatedAt:
format: date-time
type: string
updatedBy:
type: string
updatedByUser:
$ref: '#/components/schemas/TypesUser'
userId:
type: string
required:
- id
type: object
TypesIdentifiable:
properties:
id:
@@ -2107,6 +2154,16 @@ components:
required:
- id
type: object
TypesPostableAPIKey:
properties:
expiresInDays:
format: int64
type: integer
name:
type: string
role:
type: string
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
@@ -2160,6 +2217,56 @@ components:
required:
- id
type: object
TypesStorableAPIKey:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
name:
type: string
revoked:
type: boolean
role:
type: string
token:
type: string
updatedAt:
format: date-time
type: string
updatedBy:
type: string
userId:
type: string
required:
- id
type: object
TypesUser:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
email:
type: string
id:
type: string
isRoot:
type: boolean
orgId:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -2944,6 +3051,68 @@ paths:
summary: Update auth domain
tags:
- authdomains
/api/v1/export_raw_data:
post:
deprecated: false
description: This endpoints allows complex query exporting raw data for traces
and logs
operationId: HandleExportRawDataPOST
parameters:
- description: The output format for the export.
in: query
name: format
schema:
default: csv
description: The output format for the export.
enum:
- csv
- jsonl
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
type: string
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"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:
- VIEWER
- tokenizer:
- VIEWER
summary: Export raw data
tags:
- logs
- traces
/api/v1/fields/keys:
get:
deprecated: false
@@ -3599,6 +3768,222 @@ paths:
summary: Update org preference
tags:
- preferences
/api/v1/pats:
get:
deprecated: false
description: This endpoint lists all api keys
operationId: ListAPIKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/TypesGettableAPIKey'
type: array
status:
type: string
required:
- status
- 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:
- ADMIN
- tokenizer:
- ADMIN
summary: List api keys
tags:
- users
post:
deprecated: false
description: This endpoint creates an api key
operationId: CreateAPIKey
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableAPIKey'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesGettableAPIKey'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create api key
tags:
- users
/api/v1/pats/{id}:
delete:
deprecated: false
description: This endpoint revokes an api key
operationId: RevokeAPIKey
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Revoke api key
tags:
- users
put:
deprecated: false
description: This endpoint updates an api key
operationId: UpdateAPIKey
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesStorableAPIKey'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update api key
tags:
- users
/api/v1/public/dashboards/{id}:
get:
deprecated: false

View File

@@ -123,6 +123,7 @@ if err := router.Handle("/api/v1/things", handler.New(
Description: "This endpoint creates a thing",
Request: new(types.PostableThing),
RequestContentType: "application/json",
RequestQuery: new(types.QueryableThing),
Response: new(types.GettableThing),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -155,6 +156,8 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
- **Request / RequestContentType**:
- `Request` is a Go type that describes the request body or form.
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
- **RequestQuery**:
- `RequestQuery` is a Go type that descirbes query url params.
- **RequestExamples**: An array of `handler.OpenAPIExample` that provide concrete request payloads in the generated spec. See [Adding request examples](#adding-request-examples) below.
- **Response / ResponseContentType**:
- `Response` is the Go type for the successful response payload.

View File

@@ -34,22 +34,9 @@ func (server *Server) Stop(ctx context.Context) error {
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser.StringValue():
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount.StringValue():
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)

View File

@@ -213,8 +213,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {

View File

@@ -14,9 +14,10 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -49,7 +50,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't provision PAT for cloud integration:",
@@ -109,40 +110,84 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
ah.Respond(w, result)
}
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
string, *basemodel.ApiError,
) {
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
if apiErr != nil {
return "", apiErr
}
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
orgIdUUID, err := valuer.NewUUID(orgId)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't parse orgId: %w", err,
))
}
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
))
}
for _, p := range allPats {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
return p.Token, nil
}
}
slog.InfoContext(ctx, "no PAT found for cloud integration, creating a new one",
"cloud_provider", cloudProvider,
)
newPAT, err := types.NewStorableAPIKey(
integrationPATName,
integrationUser.ID,
types.RoleViewer,
0,
)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
return factorAPIKey.Key, nil
return newPAT.Token, nil
}
func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Context, orgId valuer.UUID) (*serviceaccounttypes.ServiceAccount, *basemodel.ApiError) {
prefix := ah.Signoz.Modules.ServiceAccount.Config().Prefix
cloudIntegrationServiceAccount := serviceaccounttypes.NewServiceAccount("integration", prefix, []string{authtypes.SigNozViewerRoleName}, serviceaccounttypes.StatusActive, orgId)
cloudIntegrationServiceAccount, err := ah.Signoz.Modules.ServiceAccount.GetOrCreate(ctx, cloudIntegrationServiceAccount)
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
ctx context.Context, orgId string, cloudProvider string,
) (*types.User, *basemodel.ApiError) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
return cloudIntegrationServiceAccount, nil
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
ctx,
cloudIntegrationUser,
user.WithFactorPassword(password),
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
}
return cloudIntegrationUser, nil
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -20,11 +20,113 @@ import { useMutation, useQuery } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
*/
export const handleExportRawDataPOST = (
querybuildertypesv5QueryRangeRequestDTO: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: HandleExportRawDataPOSTParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/export_raw_data`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getHandleExportRawDataPOSTMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
> => {
const mutationKey = ['handleExportRawDataPOST'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
}
> = (props) => {
const { data, params } = props ?? {};
return handleExportRawDataPOST(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type HandleExportRawDataPOSTMutationResult = NonNullable<
Awaited<ReturnType<typeof handleExportRawDataPOST>>
>;
export type HandleExportRawDataPOSTMutationBody = BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
export type HandleExportRawDataPOSTMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Export raw data
*/
export const useHandleExportRawDataPOST = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
> => {
const mutationOptions = getHandleExportRawDataPOSTMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths

View File

@@ -2209,6 +2209,10 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesPostableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -2225,6 +2229,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @format date-time
*/
createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/**
* @type string
*/
@@ -2269,6 +2278,10 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -2946,6 +2959,19 @@ export type DeleteAuthDomainPathParameters = {
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type HandleExportRawDataPOSTParams = {
/**
* @enum csv,jsonl
* @type string
* @description The output format for the export.
*/
format?: HandleExportRawDataPOSTFormat;
};
export enum HandleExportRawDataPOSTFormat {
csv = 'csv',
jsonl = 'jsonl',
}
export type GetFieldsKeysParams = {
/**
* @description undefined

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -47,6 +48,7 @@ type provider struct {
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
@@ -70,6 +72,7 @@ func NewFactory(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
@@ -96,6 +99,7 @@ func NewFactory(
gatewayHandler,
fieldsHandler,
authzHandler,
rawDataExportHandler,
zeusHandler,
querierHandler,
serviceAccountHandler,
@@ -124,6 +128,7 @@ func newProvider(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
@@ -150,6 +155,7 @@ func newProvider(
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
@@ -226,6 +232,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addRawDataExportRoutes(router); err != nil {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}

View File

@@ -0,0 +1,33 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/exporttypes"
v5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/gorilla/mux"
)
func (provider *provider) addRawDataExportRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/export_raw_data", handler.New(provider.authZ.ViewAccess(provider.rawDataExportHandler.ExportRawData), handler.OpenAPIDef{
ID: "HandleExportRawDataPOST",
Tags: []string{"logs", "traces"},
Summary: "Export raw data",
Description: "This endpoints allows complex query exporting raw data for traces and logs",
Request: new(v5.QueryRangeRequest),
RequestQuery: new(exporttypes.ExportRawDataFormatQueryParam),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -43,6 +43,74 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
ID: "CreateAPIKey",
Tags: []string{"users"},
Summary: "Create api key",
Description: "This endpoint creates an api key",
Request: new(types.PostableAPIKey),
RequestContentType: "application/json",
Response: new(types.GettableAPIKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
ID: "ListAPIKeys",
Tags: []string{"users"},
Summary: "List api keys",
Description: "This endpoint lists all api keys",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
ID: "UpdateAPIKey",
Tags: []string{"users"},
Summary: "Update api key",
Description: "This endpoint updates an api key",
Request: new(types.StorableAPIKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
ID: "RevokeAPIKey",
Tags: []string{"users"},
Summary: "Revoke api key",
Description: "This endpoint revokes an api key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},

View File

@@ -21,7 +21,7 @@ func New(store authtypes.AuthNStore) *AuthN {
}
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,5 +30,11 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(user.ID, orgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
}

View File

@@ -97,7 +97,11 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
}
if len(roles) != len(names) {
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", names)
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided names: %v", names,
)
}
return roles, nil
@@ -118,7 +122,11 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
}
if len(roles) != len(ids) {
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", ids)
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided ids: %v", ids,
)
}
return roles, nil

View File

@@ -136,22 +136,9 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector, roleSelectors []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser.StringValue():
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount.StringValue():
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)

View File

@@ -40,6 +40,17 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -79,6 +90,17 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -117,6 +139,17 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
@@ -153,28 +186,13 @@ func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(
req.Context(),
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
}
next(rw, req)
})
}

View File

@@ -63,8 +63,6 @@ func (m *IdentN) Wrap(next http.Handler) http.Handler {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("identn_provider", claims.IdentNProvider)
comment.Set("user_id", claims.UserID)
comment.Set("service_account_id", claims.ServiceAccountID)
comment.Set("principal", claims.Principal)
comment.Set("org_id", claims.OrgID)
ctx = ctxtypes.NewContextWithComment(ctx, comment)

View File

@@ -10,29 +10,33 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
// todo: will move this in types layer with service account integration
type apiKeyTokenKey struct{}
type provider struct {
serviceAccount serviceaccount.Module
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
store sqlstore.SQLStore
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func NewFactory(serviceAccount serviceaccount.Module) factory.ProviderFactory[identn.IdentN, identn.Config] {
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 New(serviceAccount, config, providerSettings)
return New(providerSettings, store, config)
})
}
func New(serviceAccount serviceaccount.Module, config identn.Config, providerSettings factory.ProviderSettings) (identn.IdentN, error) {
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, config identn.Config) (identn.IdentN, error) {
return &provider{
serviceAccount: serviceAccount,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
store: store,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
}, nil
}
@@ -50,44 +54,75 @@ func (provider *provider) Test(req *http.Request) bool {
}
func (provider *provider) Pre(req *http.Request) *http.Request {
apiKey := provider.extractToken(req)
if apiKey == "" {
token := provider.extractToken(req)
if token == "" {
return req
}
ctx := authtypes.NewContextWithAPIKey(req.Context(), apiKey)
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
return req.WithContext(ctx)
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
apiKey, err := authtypes.APIKeyFromContext(ctx)
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing api key")
}
var apiKey types.StorableAPIKey
err := provider.
store.
BunDB().
NewSelect().
Model(&apiKey).
Where("token = ?", apiKeyToken).
Scan(ctx)
if err != nil {
return nil, err
}
identity, err := provider.serviceAccount.GetIdentity(ctx, apiKey)
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.User
err = provider.
store.
BunDB().
NewSelect().
Model(&user).
Where("id = ?", apiKey.UserID).
Scan(ctx)
if err != nil {
return nil, err
}
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) {
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return
}
_, _, _ = provider.sfGroup.Do(apiKey, func() (any, error) {
if err := provider.serviceAccount.SetLastObservedAt(ctx, apiKey, time.Now()); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
return false, err
_, _, _ = provider.sfGroup.Do(apiKeyToken, func() (any, error) {
_, err := provider.
store.
BunDB().
NewUpdate().
Model(new(types.StorableAPIKey)).
Set("last_used = ?", time.Now()).
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to update last used of api key", errors.Attr(err))
}
return true, nil
})
}
func (provider *provider) extractToken(req *http.Request) string {

View File

@@ -79,15 +79,22 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
rootUser, _, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
provider.identity = authtypes.NewPrincipalUserIdentity(
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
role,
authtypes.IdentNProviderImpersonation,
)

View File

@@ -43,7 +43,7 @@ type Module interface {
Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error)
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error

View File

@@ -7,7 +7,6 @@ import (
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
@@ -23,12 +22,11 @@ import (
type handler struct {
module dashboard.Module
authz authz.AuthZ
providerSettings factory.ProviderSettings
}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, authz authz.AuthZ) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings, authz: authz}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
@@ -59,7 +57,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
dashboardMigrator.Migrate(ctx, req)
}
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.UserID), req)
if err != nil {
render.Error(rw, err)
return
@@ -157,24 +155,7 @@ func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
return
}
isAdmin := false
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, isAdmin, *req.Locked)
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, claims.Role, *req.Locked)
if err != nil {
render.Error(rw, err)
return

View File

@@ -100,13 +100,13 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return dashboard, nil
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
err = dashboard.LockUnlock(lock, role, updatedBy)
if err != nil {
return err
}

View File

@@ -6,20 +6,19 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/exporttypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -31,129 +30,31 @@ func NewHandler(module rawdataexport.Module) rawdataexport.Handler {
return &handler{module: module}
}
// ExportRawData handles data export requests.
//
// API Documentation:
// Endpoint: GET /api/v1/export_raw_data
//
// Query Parameters:
//
// - source (optional): Type of data to export ["logs" (default), "metrics", "traces"]
// Note: Currently only "logs" is fully supported
//
// - format (optional): Output format ["csv" (default), "jsonl"]
//
// - start (required): Start time for query (Unix timestamp in nanoseconds)
//
// - end (required): End time for query (Unix timestamp in nanoseconds)
//
// - limit (optional): Maximum number of rows to export
// Constraints: Must be positive and cannot exceed MAX_EXPORT_ROW_COUNT_LIMIT
//
// - filter (optional): Filter expression to apply to the query
//
// - columns (optional): Specific columns to include in export
// Default: all columns are returned
// Format: ["context.field:type", "context.field", "field"]
//
// - order_by (optional): Sorting specification ["column:direction" or "context.field:type:direction"]
// Direction: "asc" or "desc"
// Default: ["timestamp:desc", "id:desc"]
//
// Response Headers:
// - Content-Type: "text/csv" or "application/x-ndjson"
// - Content-Encoding: "gzip" (handled by HTTP middleware)
// - Content-Disposition: "attachment; filename=\"data_exported.[format]\""
// - Cache-Control: "no-cache"
// - Vary: "Accept-Encoding"
// - Transfer-Encoding: "chunked"
// - Trailers: X-Response-Complete
//
// Response Format:
//
// CSV: Headers in first row, data in subsequent rows
// JSONL: One JSON object per line
//
// Example Usage:
//
// Basic CSV export:
// GET /api/v1/export_raw_data?start=1693612800000000000&end=1693699199000000000
//
// Export with columns and format:
// GET /api/v1/export_raw_data?start=1693612800000000000&end=1693699199000000000&format=jsonl
// &columns=timestamp&columns=severity&columns=message
//
// Export with filter and ordering:
// GET /api/v1/export_raw_data?start=1693612800000000000&end=1693699199000000000
// &filter=severity="error"&order_by=timestamp:desc&limit=1000
func (handler *handler) ExportRawData(rw http.ResponseWriter, r *http.Request) {
source, err := getExportQuerySource(r.URL.Query())
if err != nil {
var queryRangeRequest qbtypes.QueryRangeRequest
var formatParam exporttypes.ExportRawDataFormatQueryParam
if err := binding.Query.BindQuery(r.URL.Query(), &formatParam); err != nil {
render.Error(rw, err)
return
}
format := formatParam.Format
if err := binding.JSON.BindBody(r.Body, &queryRangeRequest); err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := validateSpecForExport(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
switch source {
case "logs":
handler.exportLogs(rw, r)
case "traces":
handler.exportTraces(rw, r)
case "metrics":
handler.exportMetrics(rw, r)
default:
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid source: must be logs"))
}
}
func (handler *handler) exportMetrics(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported, "metrics export is not yet supported"))
}
func (handler *handler) exportTraces(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported, "traces export is not yet supported"))
}
func (handler *handler) exportLogs(rw http.ResponseWriter, r *http.Request) {
// Set up response headers
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Vary", "Accept-Encoding") // Indicate that response varies based on Accept-Encoding
rw.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, X-Response-Complete")
rw.Header().Set("Trailer", "X-Response-Complete")
rw.Header().Set("Transfer-Encoding", "chunked")
queryParams := r.URL.Query()
startTime, endTime, err := getExportQueryTimeRange(queryParams)
if err != nil {
if err := validateAndApplyDefaultExportLimits(queryRangeRequest.CompositeQuery.Queries); err != nil {
render.Error(rw, err)
return
}
limit, err := getExportQueryLimit(queryParams)
if err != nil {
render.Error(rw, err)
return
}
format, err := getExportQueryFormat(queryParams)
if err != nil {
render.Error(rw, err)
return
}
// Set appropriate content type and filename
filename := fmt.Sprintf("data_exported_%s.%s", time.Now().Format("2006-01-02_150405"), format)
rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
filterExpression := queryParams.Get("filter")
orderByExpression, err := getExportQueryOrderBy(queryParams)
if err != nil {
render.Error(rw, err)
return
}
columns := getExportQueryColumns(queryParams)
queryRangeRequest.UseDefaultOrderBy()
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -161,76 +62,98 @@ func (handler *handler) exportLogs(rw http.ResponseWriter, r *http.Request) {
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is invalid"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
queryRangeRequest := qbtypes.QueryRangeRequest{
Start: startTime,
End: endTime,
RequestType: qbtypes.RequestTypeRaw,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: nil,
},
},
},
}
setExportResponseHeaders(rw, format)
spec := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Name: "raw",
Filter: &qbtypes.Filter{
Expression: filterExpression,
},
Limit: limit,
Order: orderByExpression,
}
spec.SelectFields = columns
queryRangeRequest.CompositeQuery.Queries[0].Spec = spec
// This will signal Export module to stop sending data
doneChan := make(chan any)
defer close(doneChan)
rowChan, errChan := handler.module.ExportRawData(r.Context(), orgID, &queryRangeRequest, doneChan)
var isComplete bool
isComplete, err := handler.executeExport(rowChan, errChan, format, rw)
if err != nil {
render.Error(rw, err)
return
}
rw.Header().Set("X-Response-Complete", strconv.FormatBool(isComplete))
}
// validateSpecForExport validates query specs
func validateSpecForExport(req *qbtypes.QueryRangeRequest) error {
queries := req.CompositeQuery.Queries
// If the trace operator query is not present, and there are multiple queries, return an error
if req.TraceOperatorQueryIndex() == -1 && len(queries) > 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "multiple queries not allowed without a trace operator query")
}
for idx := range queries {
switch spec := queries[idx].Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
qbtypes.QueryBuilderTraceOperator:
// Supported spec types
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query at index %d type: %T", idx, spec)
}
}
opts := append(qbtypes.GetValidationOptions(req.RequestType), qbtypes.WithSkipLimitOffsetValidation())
return req.Validate(opts...)
}
func validateAndApplyDefaultExportLimits(queries []qbtypes.QueryEnvelope) error {
for idx := range queries {
limit := queries[idx].GetLimit()
if limit == 0 {
limit = DefaultExportRowCountLimit
} else if limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be positive")
} else if limit > MaxExportRowCountLimit {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be more than %d", MaxExportRowCountLimit)
}
queries[idx].SetLimit(limit)
}
return nil
}
// setExportResponseHeaders sets common HTTP headers for export responses.
func setExportResponseHeaders(rw http.ResponseWriter, format string) {
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Vary", "Accept-Encoding")
rw.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, X-Response-Complete")
rw.Header().Set("Trailer", "X-Response-Complete")
rw.Header().Set("Transfer-Encoding", "chunked")
filename := fmt.Sprintf("data_exported_%s.%s", time.Now().Format("2006-01-02_150405"), format)
rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
}
// executeExport streams data from rowChan to the response writer in the specified format.
func (handler *handler) executeExport(rowChan <-chan *qbtypes.RawRow, errChan <-chan error, format string, rw http.ResponseWriter) (bool, error) {
switch format {
case "csv", "":
rw.Header().Set("Content-Type", "text/csv")
csvWriter := csv.NewWriter(rw)
isComplete, err = handler.exportLogsCSV(rowChan, errChan, csvWriter)
isComplete, err := handler.exportRawDataCSV(rowChan, errChan, csvWriter)
if err != nil {
render.Error(rw, err)
return
return false, err
}
csvWriter.Flush()
return isComplete, nil
case "jsonl":
rw.Header().Set("Content-Type", "application/x-ndjson")
isComplete, err = handler.exportLogsJSONL(rowChan, errChan, rw)
if err != nil {
render.Error(rw, err)
return
}
return handler.exportRawDataJSONL(rowChan, errChan, rw)
default:
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid format: must be csv or jsonl"))
return
return false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid format: must be csv or jsonl")
}
rw.Header().Set("X-Response-Complete", strconv.FormatBool(isComplete))
}
func (handler *handler) exportLogsCSV(rowChan <-chan *qbtypes.RawRow, errChan <-chan error, csvWriter *csv.Writer) (bool, error) {
var header []string
// exportRawDataCSV is a generic CSV export function that works with any raw data (logs, traces, etc.)
func (handler *handler) exportRawDataCSV(rowChan <-chan *qbtypes.RawRow, errChan <-chan error, csvWriter *csv.Writer) (bool, error) {
headerToIndexMapping := make(map[string]int, len(header))
var header []string
headerToIndexMapping := make(map[string]int)
totalBytes := uint64(0)
for {
@@ -268,8 +191,8 @@ func (handler *handler) exportLogsCSV(rowChan <-chan *qbtypes.RawRow, errChan <-
}
}
func (handler *handler) exportLogsJSONL(rowChan <-chan *qbtypes.RawRow, errChan <-chan error, writer io.Writer) (bool, error) {
// exportRawDataJSONL is a generic JSONL export function that works with any raw data (logs, traces, etc.)
func (handler *handler) exportRawDataJSONL(rowChan <-chan *qbtypes.RawRow, errChan <-chan error, writer io.Writer) (bool, error) {
totalBytes := uint64(0)
for {
select {
@@ -277,9 +200,11 @@ func (handler *handler) exportLogsJSONL(rowChan <-chan *qbtypes.RawRow, errChan
if !ok {
return true, nil
}
// Handle JSON format (JSONL - one object per line)
jsonBytes, _ := json.Marshal(row.Data)
totalBytes += uint64(len(jsonBytes)) + 1 // +1 for newline
jsonBytes, err := json.Marshal(row.Data)
if err != nil {
return false, errors.NewUnexpectedf(errors.CodeInternal, "error marshaling JSON: %s", err)
}
totalBytes += uint64(len(jsonBytes)) + 1
if _, err := writer.Write(jsonBytes); err != nil {
return false, errors.NewUnexpectedf(errors.CodeInternal, "error writing JSON: %s", err)
@@ -299,74 +224,33 @@ func (handler *handler) exportLogsJSONL(rowChan <-chan *qbtypes.RawRow, errChan
}
}
func getExportQuerySource(queryParams url.Values) (string, error) {
switch queryParams.Get("source") {
case "logs", "":
return "logs", nil
case "metrics":
return "metrics", errors.NewInvalidInputf(errors.CodeInvalidInput, "metrics export not yet supported")
case "traces":
return "traces", errors.NewInvalidInputf(errors.CodeInvalidInput, "traces export not yet supported")
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid source: must be logs, metrics or traces")
}
}
func getExportQueryFormat(queryParams url.Values) (string, error) {
switch queryParams.Get("format") {
case "csv", "":
return "csv", nil
case "jsonl":
return "jsonl", nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid format: must be csv or jsonl")
}
}
func getExportQueryLimit(queryParams url.Values) (int, error) {
limitStr := queryParams.Get("limit")
if limitStr == "" {
return DefaultExportRowCountLimit, nil
} else {
limit, err := strconv.Atoi(limitStr)
if err != nil {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid limit format: %s", err.Error())
}
if limit <= 0 {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be positive")
}
if limit > MaxExportRowCountLimit {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be more than %d", MaxExportRowCountLimit)
}
return limit, nil
}
}
func getExportQueryTimeRange(queryParams url.Values) (uint64, uint64, error) {
startTimeStr := queryParams.Get("start")
endTimeStr := queryParams.Get("end")
if startTimeStr == "" || endTimeStr == "" {
return 0, 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "start and end time are required")
}
startTime, err := strconv.ParseUint(startTimeStr, 10, 64)
if err != nil {
return 0, 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid start time format: %s", err.Error())
}
endTime, err := strconv.ParseUint(endTimeStr, 10, 64)
if err != nil {
return 0, 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid end time format: %s", err.Error())
}
return startTime, endTime, nil
}
// priorityColumns defines the columns that should appear first in the CSV output, in order.
var priorityColumns = []string{"timestamp", "id"}
func constructCSVHeaderFromQueryResponse(data map[string]any) []string {
header := make([]string, 0, len(data))
for key := range data {
header = append(header, key)
}
// This is to ensure CSV output is consistent across multiple queries
slices.SortFunc(header, func(a, b string) int {
ai, bi := slices.Index(priorityColumns, a), slices.Index(priorityColumns, b)
switch {
case ai != -1 && bi != -1:
return ai - bi
case ai != -1:
return -1
case bi != -1:
return 1
default:
if a < b {
return -1
} else if a > b {
return 1
}
return 0
}
})
return header
}
@@ -427,9 +311,12 @@ func constructCSVRecordFromQueryResponse(data map[string]any, headerToIndexMappi
valueStr = v.String()
default:
// For all other complex types (maps, structs, etc.)
jsonBytes, _ := json.Marshal(v)
valueStr = string(jsonBytes)
jsonBytes, err := json.Marshal(v)
if err != nil {
valueStr = fmt.Sprintf("%v", v)
} else {
valueStr = string(jsonBytes)
}
}
record[index] = sanitizeForCSV(valueStr)
@@ -438,26 +325,6 @@ func constructCSVRecordFromQueryResponse(data map[string]any, headerToIndexMappi
return record
}
// getExportQueryColumns parses the "columns" query parameters and returns a slice of TelemetryFieldKey structs.
// Each column should be a valid telemetry field key in the format "context.field:type" or "context.field" or "field"
func getExportQueryColumns(queryParams url.Values) []telemetrytypes.TelemetryFieldKey {
columnParams := queryParams["columns"]
columns := make([]telemetrytypes.TelemetryFieldKey, 0, len(columnParams))
for _, columnStr := range columnParams {
// Skip empty strings
columnStr = strings.TrimSpace(columnStr)
if columnStr == "" {
continue
}
columns = append(columns, telemetrytypes.GetFieldKeyFromKeyText(columnStr))
}
return columns
}
func getsizeOfStringSlice(slice []string) uint64 {
var totalBytes uint64
for _, str := range slice {
@@ -465,52 +332,3 @@ func getsizeOfStringSlice(slice []string) uint64 {
}
return totalBytes
}
// getExportQueryOrderBy parses the "order_by" query parameters and returns a slice of OrderBy structs.
// Each "order_by" parameter should be in the format "column:direction"
// Each "column" should be a valid telemetry field key in the format "context.field:type" or "context.field" or "field"
func getExportQueryOrderBy(queryParams url.Values) ([]qbtypes.OrderBy, error) {
orderByParam := queryParams.Get("order_by")
orderByParam = strings.TrimSpace(orderByParam)
if orderByParam == "" {
return telemetrylogs.DefaultLogsV2SortingOrder, nil
}
parts := strings.Split(orderByParam, ":")
if len(parts) != 2 && len(parts) != 3 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order_by format: %s, should be <column>:<direction>", orderByParam)
}
column := strings.Join(parts[:len(parts)-1], ":")
direction := parts[len(parts)-1]
orderDirection, ok := qbtypes.OrderDirectionMap[direction]
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order_by direction: %s, should be one of %s, %s", direction, qbtypes.OrderDirectionAsc, qbtypes.OrderDirectionDesc)
}
orderByKey := telemetrytypes.GetFieldKeyFromKeyText(column)
orderBy := []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: orderByKey,
},
Direction: orderDirection,
},
}
// If we are ordering by the timestamp column, also order by the ID column
if orderByKey.Name == telemetrylogs.LogsV2TimestampColumn {
orderBy = append(orderBy, qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
Direction: orderDirection,
})
}
return orderBy, nil
}

View File

@@ -2,162 +2,84 @@ package implrawdataexport
import (
"net/url"
"strconv"
"testing"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types/exporttypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
)
func TestGetExportQuerySource(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedSource string
expectedError bool
}{
{
name: "default logs source",
queryParams: url.Values{},
expectedSource: "logs",
expectedError: false,
},
{
name: "explicit logs source",
queryParams: url.Values{"source": {"logs"}},
expectedSource: "logs",
expectedError: false,
},
{
name: "metrics source - not supported",
queryParams: url.Values{"source": {"metrics"}},
expectedSource: "metrics",
expectedError: true,
},
{
name: "traces source - not supported",
queryParams: url.Values{"source": {"traces"}},
expectedSource: "traces",
expectedError: true,
},
{
name: "invalid source",
queryParams: url.Values{"source": {"invalid"}},
expectedSource: "",
expectedError: true,
},
}
func TestExportRawDataFormatQueryParam_BindingDefaults(t *testing.T) {
var params exporttypes.ExportRawDataFormatQueryParam
err := binding.Query.BindQuery(url.Values{}, &params)
assert.NoError(t, err)
assert.Equal(t, "csv", params.Format)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source, err := getExportQuerySource(tt.queryParams)
assert.Equal(t, tt.expectedSource, source)
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
func logQuery(limit int) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{Limit: limit},
}
}
func TestGetExportQueryFormat(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedFormat string
expectedError bool
}{
{
name: "default csv format",
queryParams: url.Values{},
expectedFormat: "csv",
expectedError: false,
},
{
name: "explicit csv format",
queryParams: url.Values{"format": {"csv"}},
expectedFormat: "csv",
expectedError: false,
},
{
name: "jsonl format",
queryParams: url.Values{"format": {"jsonl"}},
expectedFormat: "jsonl",
expectedError: false,
},
{
name: "invalid format",
queryParams: url.Values{"format": {"xml"}},
expectedFormat: "",
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format, err := getExportQueryFormat(tt.queryParams)
assert.Equal(t, tt.expectedFormat, format)
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
func traceQuery(limit int) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{Limit: limit},
}
}
func TestGetExportQueryLimit(t *testing.T) {
func traceOperatorQuery(limit int) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeTraceOperator,
Spec: qbtypes.QueryBuilderTraceOperator{Limit: limit, Expression: "A"},
}
}
func makeRequest(queries ...qbtypes.QueryEnvelope) qbtypes.QueryRangeRequest {
return qbtypes.QueryRangeRequest{
Start: 1000000000000,
End: 1000003600000,
RequestType: qbtypes.RequestTypeRaw,
CompositeQuery: qbtypes.CompositeQuery{Queries: queries},
}
}
func TestValidateSpecForExport(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedLimit int
req qbtypes.QueryRangeRequest
expectedError bool
}{
{
name: "default limit",
queryParams: url.Values{},
expectedLimit: DefaultExportRowCountLimit,
expectedError: false,
name: "single log query",
req: makeRequest(logQuery(0)),
},
{
name: "valid limit",
queryParams: url.Values{"limit": {"5000"}},
expectedLimit: 5000,
expectedError: false,
name: "single trace query",
req: makeRequest(traceQuery(0)),
},
{
name: "maximum limit",
queryParams: url.Values{"limit": {strconv.Itoa(MaxExportRowCountLimit)}},
expectedLimit: MaxExportRowCountLimit,
expectedError: false,
name: "trace operator alone",
req: makeRequest(traceOperatorQuery(0)),
},
{
name: "limit exceeds maximum",
queryParams: url.Values{"limit": {"100000"}},
expectedLimit: 0,
name: "multiple queries without trace operator",
req: makeRequest(logQuery(0), traceQuery(0)),
expectedError: true,
},
{
name: "invalid limit format",
queryParams: url.Values{"limit": {"invalid"}},
expectedLimit: 0,
expectedError: true,
},
{
name: "negative limit",
queryParams: url.Values{"limit": {"-100"}},
expectedLimit: 0,
name: "unsupported query type",
req: makeRequest(qbtypes.QueryEnvelope{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{}}),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
limit, err := getExportQueryLimit(tt.queryParams)
assert.Equal(t, tt.expectedLimit, limit)
err := validateSpecForExport(&tt.req)
if tt.expectedError {
assert.Error(t, err)
} else {
@@ -167,352 +89,69 @@ func TestGetExportQueryLimit(t *testing.T) {
}
}
func TestGetExportQueryTimeRange(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedStartTime uint64
expectedEndTime uint64
expectedError bool
}{
{
name: "valid time range",
queryParams: url.Values{
"start": {"1640995200"},
"end": {"1641081600"},
},
expectedStartTime: 1640995200,
expectedEndTime: 1641081600,
expectedError: false,
},
{
name: "missing start time",
queryParams: url.Values{"end": {"1641081600"}},
expectedError: true,
},
{
name: "missing end time",
queryParams: url.Values{"start": {"1640995200"}},
expectedError: true,
},
{
name: "missing both times",
queryParams: url.Values{},
expectedError: true,
},
{
name: "invalid start time format",
queryParams: url.Values{
"start": {"invalid"},
"end": {"1641081600"},
},
expectedError: true,
},
{
name: "invalid end time format",
queryParams: url.Values{
"start": {"1640995200"},
"end": {"invalid"},
},
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
startTime, endTime, err := getExportQueryTimeRange(tt.queryParams)
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedStartTime, startTime)
assert.Equal(t, tt.expectedEndTime, endTime)
}
})
}
}
func TestGetExportQueryColumns(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedColumns []telemetrytypes.TelemetryFieldKey
}{
{
name: "no columns specified",
queryParams: url.Values{},
expectedColumns: []telemetrytypes.TelemetryFieldKey{},
},
{
name: "single column",
queryParams: url.Values{
"columns": {"timestamp"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
},
},
{
name: "multiple columns",
queryParams: url.Values{
"columns": {"timestamp", "message", "level"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
{Name: "message"},
{Name: "level"},
},
},
{
name: "empty column name (should be skipped)",
queryParams: url.Values{
"columns": {"timestamp", "", "level"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
{Name: "level"},
},
},
{
name: "whitespace column name (should be skipped)",
queryParams: url.Values{
"columns": {"timestamp", " ", "level"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
{Name: "level"},
},
},
{
name: "valid column name with data type",
queryParams: url.Values{
"columns": {"timestamp", "attribute.user:string", "level"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
{Name: "user", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "level"},
},
},
{
name: "valid column name with dot notation",
queryParams: url.Values{
"columns": {"timestamp", "attribute.user.string", "level"},
},
expectedColumns: []telemetrytypes.TelemetryFieldKey{
{Name: "timestamp"},
{Name: "user.string", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "level"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
columns := getExportQueryColumns(tt.queryParams)
assert.Equal(t, len(tt.expectedColumns), len(columns))
for i, expectedCol := range tt.expectedColumns {
assert.Equal(t, expectedCol, columns[i])
}
})
}
}
func TestGetExportQueryOrderBy(t *testing.T) {
func TestValidateAndApplyDefaultExportLimits(t *testing.T) {
tests := []struct {
name string
queryParams url.Values
expectedOrder []qbtypes.OrderBy
queries []qbtypes.QueryEnvelope
expectedError bool
checkQueries func(t *testing.T, queries []qbtypes.QueryEnvelope)
}{
{
name: "no order specified",
queryParams: url.Values{},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2TimestampColumn,
},
},
},
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
},
name: "single log query, zero limit gets default",
queries: makeRequest(logQuery(0)).CompositeQuery.Queries,
checkQueries: func(t *testing.T, q []qbtypes.QueryEnvelope) {
assert.Equal(t, DefaultExportRowCountLimit, q[0].GetLimit())
},
expectedError: false,
},
{
name: "single order error, direction not specified",
queryParams: url.Values{
"order_by": {"timestamp"},
name: "single log query, valid limit kept",
queries: makeRequest(logQuery(1000)).CompositeQuery.Queries,
checkQueries: func(t *testing.T, q []qbtypes.QueryEnvelope) {
assert.Equal(t, 1000, q[0].GetLimit())
},
expectedOrder: nil,
},
{
name: "single log query, max limit kept",
queries: makeRequest(logQuery(MaxExportRowCountLimit)).CompositeQuery.Queries,
checkQueries: func(t *testing.T, q []qbtypes.QueryEnvelope) {
assert.Equal(t, MaxExportRowCountLimit, q[0].GetLimit())
},
},
{
name: "single log query, limit exceeds max",
queries: makeRequest(logQuery(MaxExportRowCountLimit + 1)).CompositeQuery.Queries,
expectedError: true,
},
{
name: "single order no error",
queryParams: url.Values{
"order_by": {"timestamp:asc"},
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2TimestampColumn,
},
},
},
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
},
},
expectedError: false,
},
{
name: "multiple orders",
queryParams: url.Values{
"order_by": {"timestamp:asc", "body:desc", "id:asc"},
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2TimestampColumn,
},
},
},
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
},
},
expectedError: false,
},
{
name: "empty order name (should be skipped)",
queryParams: url.Values{
"order_by": {"timestamp:asc", "", "id:asc"},
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2TimestampColumn,
},
},
},
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
},
},
expectedError: false,
},
{
name: "whitespace order name (should be skipped)",
queryParams: url.Values{
"order_by": {"timestamp:asc", " ", "id:asc"},
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2TimestampColumn,
},
},
},
{
Direction: qbtypes.OrderDirectionAsc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: telemetrylogs.LogsV2IDColumn,
},
},
},
},
expectedError: false,
},
{
name: "invalid order name (should error out)",
queryParams: url.Values{
"order_by": {"attributes.user:", "id:asc"},
},
expectedOrder: nil,
name: "single log query, negative limit",
queries: makeRequest(logQuery(-1)).CompositeQuery.Queries,
expectedError: true,
},
{
name: "valid order name (should be included)",
queryParams: url.Values{
"order_by": {"attribute.user:string:desc", "id:asc"},
name: "single trace query, zero limit gets default",
queries: makeRequest(traceQuery(0)).CompositeQuery.Queries,
checkQueries: func(t *testing.T, q []qbtypes.QueryEnvelope) {
assert.Equal(t, DefaultExportRowCountLimit, q[0].GetLimit())
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "user",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
},
expectedError: false,
},
{
name: "valid order name (should be included)",
queryParams: url.Values{
"order_by": {"attribute.user.string:desc", "id:asc"},
name: "trace operator alone, zero limit gets default",
queries: makeRequest(traceOperatorQuery(0)).CompositeQuery.Queries,
checkQueries: func(t *testing.T, q []qbtypes.QueryEnvelope) {
assert.Equal(t, DefaultExportRowCountLimit, q[0].GetLimit())
},
expectedOrder: []qbtypes.OrderBy{
{
Direction: qbtypes.OrderDirectionDesc,
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "user.string",
FieldContext: telemetrytypes.FieldContextAttribute,
},
},
},
},
expectedError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
order, err := getExportQueryOrderBy(tt.queryParams)
err := validateAndApplyDefaultExportLimits(tt.queries)
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, len(tt.expectedOrder), len(order))
for i, expectedOrd := range tt.expectedOrder {
assert.Equal(t, expectedOrd, order[i])
if tt.checkQueries != nil {
tt.checkQueries(t, tt.queries)
}
}
})
@@ -529,13 +168,8 @@ func TestConstructCSVHeaderFromQueryResponse(t *testing.T) {
header := constructCSVHeaderFromQueryResponse(data)
// Since map iteration order is not guaranteed, check that all expected keys are present
expectedKeys := []string{"timestamp", "message", "level", "id"}
assert.Equal(t, len(expectedKeys), len(header))
for _, key := range expectedKeys {
assert.Contains(t, header, key)
}
// Priority columns come first in order, then the rest alphabetically.
assert.Equal(t, []string{"timestamp", "id", "level", "message"}, header)
}
func TestConstructCSVRecordFromQueryResponse(t *testing.T) {

View File

@@ -28,8 +28,18 @@ func (m *Module) ExportRawData(ctx context.Context, orgID valuer.UUID, rangeRequ
instrumentationtypes.CodeFunctionName: "ExportRawData",
})
spec := rangeRequest.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.LogAggregation])
rowCountLimit := spec.Limit
traceOperatorQueryIndex := rangeRequest.TraceOperatorQueryIndex()
queries := rangeRequest.CompositeQuery.Queries
// If the trace operator query is present, mark the queries other than trace operator as disabled
if traceOperatorQueryIndex > -1 {
for idx := range len(queries) {
if idx != traceOperatorQueryIndex {
queries[idx].SetDisabled(true)
}
}
}
rowChan := make(chan *qbtypes.RawRow, 1)
errChan := make(chan error, 1)
@@ -43,52 +53,62 @@ func (m *Module) ExportRawData(ctx context.Context, orgID valuer.UUID, rangeRequ
defer close(errChan)
defer close(rowChan)
rowCount := 0
for rowCount < rowCountLimit {
spec.Limit = min(ChunkSize, rowCountLimit-rowCount)
spec.Offset = rowCount
rangeRequest.CompositeQuery.Queries[0].Spec = spec
response, err := m.querier.QueryRange(contextWithTimeout, orgID, rangeRequest)
if err != nil {
errChan <- err
return
}
newRowsCount := 0
for _, result := range response.Data.Results {
resultData, ok := result.(*qbtypes.RawData)
if !ok {
errChan <- errors.NewInternalf(errors.CodeInternal, "expected RawData, got %T", result)
return
}
newRowsCount += len(resultData.Rows)
for _, row := range resultData.Rows {
select {
case rowChan <- row:
case <-doneChan:
return
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
}
// Break if we did not receive any new rows
if newRowsCount == 0 {
return
}
rowCount += newRowsCount
if traceOperatorQueryIndex > -1 {
// If the trace operator query is present, we need to export the data for the trace operator query only
exportRawDataForSingleQuery(m.querier, contextWithTimeout, orgID, rangeRequest, rowChan, errChan, doneChan, traceOperatorQueryIndex)
} else {
// If the trace operator query is not present, we need to export the data for the first query only
exportRawDataForSingleQuery(m.querier, contextWithTimeout, orgID, rangeRequest, rowChan, errChan, doneChan, 0)
}
}()
return rowChan, errChan
}
func exportRawDataForSingleQuery(querier querier.Querier, ctx context.Context, orgID valuer.UUID, rangeRequest *qbtypes.QueryRangeRequest, rowChan chan *qbtypes.RawRow, errChan chan error, doneChan chan any, queryIndex int) {
queries := rangeRequest.CompositeQuery.Queries
rowCountLimit := queries[queryIndex].GetLimit()
rowCount := 0
for rowCount < rowCountLimit {
chunkSize := min(ChunkSize, rowCountLimit-rowCount)
queries[queryIndex].SetLimit(chunkSize)
queries[queryIndex].SetOffset(rowCount)
response, err := querier.QueryRange(ctx, orgID, rangeRequest)
if err != nil {
errChan <- err
return
}
newRowsCount := 0
for _, result := range response.Data.Results {
resultData, ok := result.(*qbtypes.RawData)
if !ok {
errChan <- errors.NewInternalf(errors.CodeInternal, "expected RawData, got %T", result)
return
}
newRowsCount += len(resultData.Rows)
for _, row := range resultData.Rows {
select {
case rowChan <- row:
case <-doneChan:
return
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
}
rowCount += newRowsCount
// Stop if we received fewer rows than requested — no more data available
if newRowsCount < chunkSize {
return
}
}
}

View File

@@ -1,29 +0,0 @@
package serviceaccount
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
)
type Config struct {
Prefix string `mapstructure:"prefix"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("serviceaccount"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Prefix: "signoz",
}
}
func (c Config) Validate() error {
if c.Prefix == "" {
return errors.New(errors.TypeInvalidInput, serviceaccounttypes.ErrCodeServiceAccountInvalidConfig, "prefix cannot be empty")
}
return nil
}

View File

@@ -35,7 +35,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, handler.module.Config().Prefix, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, req.Email, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
err = handler.module.Create(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
if err != nil {
render.Error(rw, err)
@@ -111,7 +111,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = serviceAccount.Update(req.Name, req.Roles)
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
if err != nil {
render.Error(rw, err)
return
@@ -299,12 +299,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
return
}
err = factorAPIKey.Update(req.Name, req.ExpiresAt)
if err != nil {
render.Error(rw, err)
return
}
factorAPIKey.Update(req.Name, req.ExpiresAt)
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
if err != nil {
render.Error(rw, err)

View File

@@ -2,36 +2,28 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
emptyOrgID valuer.UUID = valuer.UUID{}
)
type module struct {
store serviceaccounttypes.Store
authz authz.AuthZ
cache cache.Cache
analytics analytics.Analytics
settings factory.ScopedProviderSettings
config serviceaccount.Config
store serviceaccounttypes.Store
authz authz.AuthZ
emailing emailing.Emailing
settings factory.ScopedProviderSettings
}
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, cache cache.Cache, analytics analytics.Analytics, providerSettings factory.ProviderSettings, config serviceaccount.Config) serviceaccount.Module {
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
return &module{store: store, authz: authz, cache: cache, analytics: analytics, settings: settings, config: config}
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
@@ -66,8 +58,6 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return nil
}
@@ -78,7 +68,7 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
}
if existingServiceAccount != nil {
return serviceaccounttypes.NewServiceAccountFromStorables(existingServiceAccount, serviceAccount.Roles), nil
return serviceAccount, nil
}
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
@@ -86,8 +76,6 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
return nil, err
}
module.analytics.IdentifyUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return serviceAccount, nil
}
@@ -198,8 +186,6 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Updated", input.Traits())
return nil
}
@@ -228,11 +214,6 @@ func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input
return err
}
// delete the cache when updating status for service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(input.ID))
module.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -270,11 +251,6 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
// delete the cache when deleting service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(id))
module.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), id.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -287,36 +263,22 @@ func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serv
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
if err != nil {
return err
}
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "New API Key created for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
return nil
}
func (module *module) GetOrCreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error) {
existingFactorAPIKey, err := module.store.GetFactorAPIKeyByName(ctx, factorAPIKey.ServiceAccountID, factorAPIKey.Name)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingFactorAPIKey != nil {
return serviceaccounttypes.NewFactorAPIKeyFromStorable(existingFactorAPIKey), nil
}
err = module.CreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return nil, err
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
}
return factorAPIKey, nil
}
func (module *module) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
storableFactorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
if err != nil {
@@ -335,15 +297,12 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
}
func (module *module) UpdateFactorAPIKey(ctx context.Context, orgID valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
if err != nil {
return err
}
// delete the cache when updating the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.analytics.TrackUser(ctx, orgID.String(), serviceAccountID.String(), "API Key updated", factorAPIKey.Traits())
return nil
}
@@ -363,108 +322,14 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
return err
}
// delete the cache when revoking the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccountID.String(), "API Key revoked", factorAPIKey.Traits())
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
return nil
}
func (module *module) Config() serviceaccount.Config {
return module.config
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.count"] = count
}
count, err = module.store.CountFactorAPIKeysByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.keys.count"] = count
}
return stats, nil
}
func (module *module) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
apiKey, err := module.getOrGetSetAPIKey(ctx, key)
if err != nil {
return nil, err
}
if err := apiKey.IsExpired(); err != nil {
return nil, err
}
identity, err := module.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
if err != nil {
return nil, err
}
return identity, nil
}
func (module *module) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
return module.store.UpdateLastObservedAt(ctx, key, lastObservedAt)
}
func (module *module) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
factorAPIkey := new(serviceaccounttypes.FactorAPIKey)
err := module.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return factorAPIkey, nil
}
storable, err := module.store.GetFactorAPIKeyByKey(ctx, key)
if err != nil {
return nil, err
}
factorAPIkey = serviceaccounttypes.NewFactorAPIKeyFromStorable(storable)
err = module.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey, time.Duration(factorAPIkey.ExpiresAt))
if err != nil {
return nil, err
}
return factorAPIkey, nil
}
func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID valuer.UUID) (*authtypes.Identity, error) {
identity := new(authtypes.Identity)
err := module.cache.Get(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return identity, nil
}
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
if err != nil {
return nil, err
}
identity = storableServiceAccount.ToIdentity()
err = module.cache.Set(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity, 0)
if err != nil {
return nil, err
}
return identity, nil
}
func apiKeyCacheKey(apiKey string) string {
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
}
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -2,7 +2,6 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
@@ -85,23 +84,6 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
return storable, nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableServiceAccount)
count, err := store.
sqlstore.
BunDB().
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccount, error) {
storables := make([]*serviceaccounttypes.StorableServiceAccount, 0)
@@ -249,60 +231,6 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
return storable, nil
}
func (store *store) GetFactorAPIKeyByKey(ctx context.Context, key string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("key = ?", key).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with key: %s doesn't exist.", key)
}
return storable, nil
}
func (store *store) GetFactorAPIKeyByName(ctx context.Context, serviceAccountID valuer.UUID, name string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("service_account_id = ?", serviceAccountID.String()).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with name: %s doesn't exist in service account: %s", name, serviceAccountID.String())
}
return storable, nil
}
func (store *store) CountFactorAPIKeysByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
count, err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Join("JOIN service_account").
JoinOn("service_account.id = factor_api_key.service_account_id").
Where("service_account.org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
@@ -326,7 +254,6 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
BunDBCtx(ctx).
NewUpdate().
Model(storable).
WherePK().
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
@@ -336,22 +263,6 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
return nil
}
func (store *store) UpdateLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
TableExpr("factor_api_key").
Set("last_observed_at = ?", lastObservedAt).
Where("key = ?", key).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -3,9 +3,7 @@ package serviceaccount
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -41,24 +39,14 @@ type Module interface {
// Gets a factor API key by id
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error)
GetOrCreateFactorAPIKey(context.Context, *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error)
// Lists all the API keys for a service account
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
// Updates an existing API key for a service account
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
// Set the last observed at for an api key.
SetLastObservedAt(context.Context, string, time.Time) error
// Revokes an existing API key for a service account
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
// Gets the identity for service account based on the factor api key.
GetIdentity(context.Context, string) (*authtypes.Identity, error)
Config() Config
}
type Handler interface {

View File

@@ -160,7 +160,18 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewPrincipalUserIdentity(newUser.ID, newUser.OrgID, newUser.Email, authtypes.IdentNProviderTokenizer), map[string]string{})
userRoles, err := module.userGetter.GetUserRoles(ctx, newUser.ID)
if err != nil {
return "", err
}
if len(userRoles) == 0 {
return "", errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
finalRole := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(newUser.ID, newUser.OrgID, newUser.Email, finalRole, authtypes.IdentNProviderTokenizer), map[string]string{})
if err != nil {
return "", err
}

View File

@@ -35,7 +35,7 @@ func (h *handler) GetSpanPercentileDetails(w http.ResponseWriter, r *http.Reques
return
}
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), spanPercentileRequest)
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
if err != nil {
render.Error(w, err)
return

View File

@@ -28,7 +28,7 @@ func NewModule(
}
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "spanpercentile",
instrumentationtypes.CodeFunctionName: "GetSpanPercentile",

View File

@@ -9,7 +9,7 @@ import (
)
type Module interface {
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -12,6 +13,7 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -41,7 +43,7 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
return
}
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &types.PostableBulkInviteRequest{
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &types.PostableBulkInviteRequest{
Invites: []types.PostableInvite{req},
})
if err != nil {
@@ -74,7 +76,7 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
return
}
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &req)
if err != nil {
render.Error(rw, err)
return
@@ -160,7 +162,7 @@ func (h *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user, claims.UserID)
if err != nil {
render.Error(w, err)
return
@@ -181,7 +183,7 @@ func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.IdentityID()); err != nil {
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
render.Error(w, err)
return
}
@@ -272,3 +274,172 @@ func (h *handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := new(types.PostableAPIKey)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
apiKey, err := types.NewStorableAPIKey(
req.Name,
valuer.MustNewUUID(claims.UserID),
req.Role,
req.ExpiresInDays,
)
if err != nil {
render.Error(w, err)
return
}
err = h.setter.CreateAPIKey(ctx, apiKey)
if err != nil {
render.Error(w, err)
return
}
createdApiKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), apiKey.ID)
if err != nil {
render.Error(w, err)
return
}
// just corrected the status code, response is same,
render.Success(w, http.StatusCreated, createdApiKey)
}
func (h *handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
apiKeys, err := h.setter.ListAPIKeys(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(w, err)
return
}
// for backward compatibility
if len(apiKeys) == 0 {
render.Success(w, http.StatusOK, []types.GettableAPIKey{})
return
}
result := make([]*types.GettableAPIKey, len(apiKeys))
for i, apiKey := range apiKeys {
result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey)
}
render.Success(w, http.StatusOK, result)
}
func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := types.StorableAPIKey{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
err = h.setter.UpdateAPIKey(ctx, id, &req, valuer.MustNewUUID(claims.UserID))
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
if err := h.setter.RevokeAPIKey(ctx, id, valuer.MustNewUUID(claims.UserID)); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@@ -56,7 +56,12 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
}
// CreateBulk implements invite.Module.
func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
creator, err := module.store.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
@@ -123,7 +128,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, i
// send password reset emails to all the invited users
for idx, userWithToken := range newUsersWithResetToken {
module.analytics.TrackUser(ctx, orgID.String(), identityID.String(), "Invite Sent", map[string]any{
module.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.Role,
})
@@ -157,7 +162,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, i
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
if err := module.emailing.SendHTML(ctx, userWithToken.User.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": identityEmail.StringValue(),
"inviter_email": creator.Email,
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
@@ -215,12 +220,7 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
return nil
}
func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser, updatedBy string) (*types.DeprecatedUser, error) {
existingUser, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return nil, err
@@ -234,29 +234,19 @@ func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id stri
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
}
requestor, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
}
roleChange := user.Role != "" && user.Role != existingUser.Role
if roleChange {
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = module.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
if roleChange && requestor.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// make sure the user is not demoting self from admin
if roleChange && existingUser.ID == valuer.MustNewUUID(claims.IdentityID()) && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
if roleChange && existingUser.ID == requestor.ID && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot change self role")
}
@@ -638,6 +628,26 @@ func (module *setter) GetOrCreateUser(ctx context.Context, user *types.User, opt
return user, nil
}
func (module *setter) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
return module.store.CreateAPIKey(ctx, apiKey)
}
func (module *setter) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
return module.store.UpdateAPIKey(ctx, id, apiKey, updaterID)
}
func (module *setter) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
return module.store.ListAPIKeys(ctx, orgID)
}
func (module *setter) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
return module.store.GetAPIKey(ctx, orgID, id)
}
func (module *setter) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error {
return module.store.RevokeAPIKey(ctx, id, removedByUserID)
}
func (module *setter) 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)
if err != nil {
@@ -693,6 +703,11 @@ func (module *setter) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["user.count.pending_invite"] = counts[types.UserStatusPendingInvite]
}
count, err := module.store.CountAPIKeyByOrgID(ctx, orgID)
if err == nil {
stats["factor.api_key.count"] = count
}
return stats, nil
}

View File

@@ -3,6 +3,7 @@ package impluser
import (
"context"
"database/sql"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -181,6 +182,15 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
}
// delete api keys
_, err = tx.NewDelete().
Model(&types.StorableAPIKey{}).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys")
}
// delete user_preference
_, err = tx.NewDelete().
Model(new(preferencetypes.StorableUserPreference)).
@@ -256,6 +266,15 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
}
// delete api keys
_, err = tx.NewDelete().
Model(&types.StorableAPIKey{}).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys")
}
// delete user_preference
_, err = tx.NewDelete().
Model(new(preferencetypes.StorableUserPreference)).
@@ -402,6 +421,111 @@ func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.Fa
return nil
}
// --- API KEY ---
func (store *store) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
_, err := store.sqlstore.BunDB().NewInsert().
Model(apiKey).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrAPIKeyAlreadyExists, "API key with token: %s already exists", apiKey.Token)
}
return nil
}
func (store *store) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
apiKey.UpdatedBy = updaterID.String()
apiKey.UpdatedAt = time.Now()
_, err := store.sqlstore.BunDB().NewUpdate().
Model(apiKey).
Column("role", "name", "updated_at", "updated_by").
Where("id = ?", id).
Where("revoked = false").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
return nil
}
func (store *store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
orgUserAPIKeys := new(types.OrgUserAPIKey)
if err := store.sqlstore.BunDB().NewSelect().
Model(orgUserAPIKeys).
Relation("Users").
Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("revoked = false")
},
).
Relation("Users.APIKeys.CreatedByUser").
Relation("Users.APIKeys.UpdatedByUser").
Where("id = ?", orgID).
Scan(ctx); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to fetch API keys")
}
// Flatten the API keys from all users
var allAPIKeys []*types.StorableAPIKeyUser
for _, user := range orgUserAPIKeys.Users {
if user.APIKeys != nil {
allAPIKeys = append(allAPIKeys, user.APIKeys...)
}
}
// sort the API keys by updated_at
sort.Slice(allAPIKeys, func(i, j int) bool {
return allAPIKeys[i].UpdatedAt.After(allAPIKeys[j].UpdatedAt)
})
return allAPIKeys, nil
}
func (store *store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUID) error {
updatedAt := time.Now().Unix()
_, err := store.sqlstore.BunDB().NewUpdate().
Model(&types.StorableAPIKey{}).
Set("revoked = ?", true).
Set("updated_by = ?", revokedByUserID).
Set("updated_at = ?", updatedAt).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to revoke API key")
}
return nil
}
func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
apiKey := new(types.OrgUserAPIKey)
if err := store.sqlstore.BunDB().NewSelect().
Model(apiKey).
Relation("Users").
Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("revoked = false").Where("storable_api_key.id = ?", id).
OrderExpr("storable_api_key.updated_at DESC").Limit(1)
},
).
Relation("Users.APIKeys.CreatedByUser").
Relation("Users.APIKeys.UpdatedByUser").
Scan(ctx); err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
// flatten the API keys
flattenedAPIKeys := []*types.StorableAPIKeyUser{}
for _, user := range apiKey.Users {
if user.APIKeys != nil {
flattenedAPIKeys = append(flattenedAPIKeys, user.APIKeys...)
}
}
if len(flattenedAPIKeys) == 0 {
return nil, store.sqlstore.WrapNotFoundErrf(errors.New(errors.TypeNotFound, errors.CodeNotFound, "API key with id: %s does not exist"), types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
return flattenedAPIKeys[0], nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
user := new(types.User)
@@ -449,6 +573,24 @@ func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UU
return counts, nil
}
func (store *store) CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
apiKey := new(types.StorableAPIKey)
count, err := store.
sqlstore.
BunDB().
NewSelect().
Model(apiKey).
Join("JOIN users ON users.id = storable_api_key.user_id").
Where("org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)

View File

@@ -34,14 +34,21 @@ type Setter interface {
// Initiate forgot password flow for a user
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser, updatedBy string) (*types.DeprecatedUser, error)
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.DeprecatedUser) error
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
// invite
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
// API KEY
CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error)
// Roles
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
@@ -97,4 +104,10 @@ type Handler interface {
ResetPassword(http.ResponseWriter, *http.Request)
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
// API KEY
CreateAPIKey(http.ResponseWriter, *http.Request)
ListAPIKeys(http.ResponseWriter, *http.Request)
UpdateAPIKey(http.ResponseWriter, *http.Request)
RevokeAPIKey(http.ResponseWriter, *http.Request)
}

View File

@@ -268,9 +268,9 @@ func (handler *handler) logEvent(ctx context.Context, referrer string, event *qb
}
if !event.HasData {
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
}

View File

@@ -576,9 +576,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
aH.LicensingAPI.Activate(rw, req)
})).Methods(http.MethodGet)
// Export
router.HandleFunc("/api/v1/export_raw_data", am.ViewAccess(aH.Signoz.Handlers.RawDataExport.ExportRawData)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
// Query Filter Analyzer api used to extract metric names and grouping columns from a query
@@ -1567,7 +1564,7 @@ func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
if errv2 == nil {
switch request.EventType {
case model.TrackEvent:
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), request.EventName, request.Attributes)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, request.EventName, request.Attributes)
}
aH.WriteJSON(w, r, map[string]string{"data": "Event Processed Successfully"})
} else {
@@ -4670,7 +4667,7 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
// Check if result is empty or has no data
if len(result) == 0 {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
@@ -4680,18 +4677,18 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
if len(result[0].List) == 0 {
// Check if first result has no table data
if result[0].Table == nil {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
if len(result[0].Table.Rows) == 0 {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
}
}
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
}

View File

@@ -22,7 +22,6 @@ import (
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/pprof"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -120,9 +119,6 @@ type Config struct {
// IdentN config
IdentN identn.Config `mapstructure:"identn"`
// ServiceAccount config
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -152,7 +148,6 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
flagger.NewConfigFactory(),
user.NewConfigFactory(),
identn.NewConfigFactory(),
serviceaccount.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -75,7 +75,7 @@ func NewHandlers(
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, authz),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),

View File

@@ -114,6 +114,6 @@ func NewModules(
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, emailing, providerSettings),
}
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -59,6 +60,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ gateway.Handler }{},
struct{ fields.Handler }{},
struct{ authz.Handler }{},
struct{ rawdataexport.Handler }{},
struct{ zeus.Handler }{},
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},

View File

@@ -31,7 +31,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
"github.com/SigNoz/signoz/pkg/modules/promote/implpromote"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -191,9 +190,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdatePlannedMaintenanceRuleFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserRoleFactory(sqlstore, sqlschema),
sqlmigration.NewDropUserRoleColumnFactory(sqlstore, sqlschema),
sqlmigration.NewAddServiceAccountFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateAPIKeyFactory(sqlstore, sqlschema),
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
)
}
@@ -278,6 +274,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.GatewayHandler,
handlers.Fields,
handlers.AuthzHandler,
handlers.RawDataExport,
handlers.ZeusHandler,
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
@@ -294,11 +291,11 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore
)
}
func NewIdentNProviderFactories(tokenizer tokenizer.Tokenizer, serviceAccount serviceaccount.Module, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
return factory.MustNewNamedMap(
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
tokenizeridentn.NewFactory(tokenizer),
apikeyidentn.NewFactory(serviceAccount),
apikeyidentn.NewFactory(sqlstore),
)
}

View File

@@ -411,7 +411,7 @@ func New(
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore)
// Initialize identN resolver
identNFactories := NewIdentNProviderFactories(tokenizer, modules.ServiceAccount, orgGetter, userGetter, config.User)
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err

View File

@@ -1,133 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addServiceAccount struct {
sqlschema sqlschema.SQLSchema
sqlstore sqlstore.SQLStore
}
func NewAddServiceAccountFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_service_account"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addServiceAccount{
sqlschema: sqlschema,
sqlstore: sqlstore,
}, nil
})
}
func (migration *addServiceAccount) Register(migrations *migrate.Migrations) error {
err := migrations.Register(migration.Up, migration.Down)
if err != nil {
return err
}
return nil
}
func (migration *addServiceAccount) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "service_account",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "email", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "status", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "service_account",
ColumnNames: []sqlschema.ColumnName{"name", "org_id"},
Where: "status != 'disabled'",
})
sqls = append(sqls, indexSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "service_account",
ColumnNames: []sqlschema.ColumnName{"email", "org_id"},
Where: "status != 'disabled'",
})
sqls = append(sqls, indexSQLs...)
tableSQLs = migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "service_account_role",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "service_account_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "role_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("service_account_id"),
ReferencedTableName: sqlschema.TableName("service_account"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("role_id"),
ReferencedTableName: sqlschema.TableName("role"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "service_account_role", ColumnNames: []sqlschema.ColumnName{"service_account_id", "role_id"}})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (a *addServiceAccount) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,359 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// sanitizeForEmail converts an arbitrary string into a valid email local part
// by replacing any character that is not alphanumeric, dot, hyphen, or underscore
// with a hyphen, then collapsing consecutive hyphens and trimming leading/trailing hyphens.
var nonEmailLocalPartRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func sanitizeForEmail(name string) string {
s := nonEmailLocalPartRe.ReplaceAllString(name, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "service-account"
}
return s
}
type oldFactorAPIKey68 struct {
bun.BaseModel `bun:"table:factor_api_key"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Token string `bun:"token"`
Role string `bun:"role"`
Name string `bun:"name"`
ExpiresAt time.Time `bun:"expires_at"`
LastUsed time.Time `bun:"last_used"`
Revoked bool `bun:"revoked"`
UserID string `bun:"user_id"`
}
type oldUser68 struct {
bun.BaseModel `bun:"table:users"`
types.Identifiable
DisplayName string `bun:"display_name"`
Email string `bun:"email"`
OrgID string `bun:"org_id"`
}
type oldRole68 struct {
bun.BaseModel `bun:"table:role"`
types.Identifiable
Name string `bun:"name"`
OrgID string `bun:"org_id"`
}
type newServiceAccount68 struct {
bun.BaseModel `bun:"table:service_account"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name"`
Email string `bun:"email"`
Status string `bun:"status"`
OrgID string `bun:"org_id"`
}
type newServiceAccountRole68 struct {
bun.BaseModel `bun:"table:service_account_role"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
ServiceAccountID string `bun:"service_account_id"`
RoleID string `bun:"role_id"`
}
type newFactorAPIKey68 struct {
bun.BaseModel `bun:"table:factor_api_key"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name"`
Key string `bun:"key"`
ExpiresAt uint64 `bun:"expires_at"`
LastObservedAt time.Time `bun:"last_observed_at"`
ServiceAccountID string `bun:"service_account_id"`
}
type deprecateAPIKey struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewDeprecateAPIKeyFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("deprecate_api_key"), func(_ context.Context, _ factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deprecateAPIKey{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *deprecateAPIKey) Register(migrations *migrate.Migrations) error {
err := migrations.Register(migration.Up, migration.Down)
if err != nil {
return err
}
return nil
}
func (migration *deprecateAPIKey) Up(ctx context.Context, db *bun.DB) error {
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("factor_api_key"))
if err != nil {
return err
}
hasOldSchema := false
for _, col := range table.Columns {
if col.Name == "user_id" {
hasOldSchema = true
break
}
}
if !hasOldSchema {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// get all the api keys
oldKeys := make([]*oldFactorAPIKey68, 0)
err = tx.NewSelect().Model(&oldKeys).Where("revoked = ?", false).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
// get all the unique users
userIDs := make(map[string]struct{})
for _, key := range oldKeys {
userIDs[key.UserID] = struct{}{}
}
userIDList := make([]string, 0, len(userIDs))
for uid := range userIDs {
userIDList = append(userIDList, uid)
}
userMap := make(map[string]*oldUser68)
if len(userIDList) > 0 {
users := make([]*oldUser68, 0)
err = tx.NewSelect().Model(&users).Where("id IN (?)", bun.In(userIDList)).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
for _, u := range users {
userMap[u.ID.String()] = u
}
}
// get the role ids
type orgRoleKey struct {
OrgID string
RoleName string
}
roleMap := make(map[orgRoleKey]string)
if len(userMap) > 0 {
orgIDs := make(map[string]struct{})
for _, u := range userMap {
orgIDs[u.OrgID] = struct{}{}
}
orgIDList := make([]string, 0, len(orgIDs))
for oid := range orgIDs {
orgIDList = append(orgIDList, oid)
}
roles := make([]*oldRole68, 0)
err = tx.NewSelect().Model(&roles).Where("org_id IN (?)", bun.In(orgIDList)).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
for _, r := range roles {
roleMap[orgRoleKey{OrgID: r.OrgID, RoleName: r.Name}] = r.ID.String()
}
}
serviceAccounts := make([]*newServiceAccount68, 0)
serviceAccountRoles := make([]*newServiceAccountRole68, 0)
newKeys := make([]*newFactorAPIKey68, 0)
// Track used names per org for deduplication.
// Names are sanitized first so that dedup, Name, and email all derive
// from the same value — avoiding collisions on the unique (email, org_id) index.
orgNameCount := make(map[string]map[string]int) // orgID -> sanitized name -> count
now := time.Now()
for _, oldKey := range oldKeys {
user, ok := userMap[oldKey.UserID]
if !ok {
// this should never happen as a key cannot exist without a user
continue
}
// Sanitize first, then deduplicate within the same org
if orgNameCount[user.OrgID] == nil {
orgNameCount[user.OrgID] = make(map[string]int)
}
baseName := sanitizeForEmail(oldKey.Name)
count := orgNameCount[user.OrgID][baseName]
finalName := baseName
if count > 0 {
finalName = fmt.Sprintf("%s-%d", baseName, count)
}
orgNameCount[user.OrgID][baseName] = count + 1
saID := valuer.GenerateUUID()
serviceAccounts = append(serviceAccounts, &newServiceAccount68{
Identifiable: types.Identifiable{ID: saID},
CreatedAt: now,
UpdatedAt: now,
Name: finalName,
Email: fmt.Sprintf("%s@signoz.serviceaccount.io", finalName),
Status: "active",
OrgID: user.OrgID,
})
managedRoleName, ok := authtypes.ExistingRoleToSigNozManagedRoleMap[types.Role(oldKey.Role)]
if !ok {
managedRoleName = authtypes.SigNozViewerRoleName
}
roleID, ok := roleMap[orgRoleKey{OrgID: user.OrgID, RoleName: managedRoleName}]
if ok {
serviceAccountRoles = append(serviceAccountRoles, &newServiceAccountRole68{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
CreatedAt: now,
UpdatedAt: now,
ServiceAccountID: saID.String(),
RoleID: roleID,
})
}
var expiresAtUnix uint64
if !oldKey.ExpiresAt.IsZero() && oldKey.ExpiresAt.Unix() > 0 {
expiresAtUnix = uint64(oldKey.ExpiresAt.Unix())
}
// Convert last_used to last_observed_at.
lastObservedAt := oldKey.LastUsed
if lastObservedAt.IsZero() {
lastObservedAt = oldKey.CreatedAt
}
newKeys = append(newKeys, &newFactorAPIKey68{
Identifiable: oldKey.Identifiable,
CreatedAt: oldKey.CreatedAt,
UpdatedAt: oldKey.UpdatedAt,
Name: oldKey.Name,
Key: oldKey.Token,
ExpiresAt: expiresAtUnix,
LastObservedAt: lastObservedAt,
ServiceAccountID: saID.String(),
})
}
if len(serviceAccounts) > 0 {
if _, err := tx.NewInsert().Model(&serviceAccounts).Exec(ctx); err != nil {
return err
}
}
if len(serviceAccountRoles) > 0 {
if _, err := tx.NewInsert().Model(&serviceAccountRoles).Exec(ctx); err != nil {
return err
}
}
sqls := [][]byte{}
deprecatedFactorAPIKey, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("factor_api_key"))
if err != nil {
return err
}
dropTableSQLS := migration.sqlschema.Operator().DropTable(deprecatedFactorAPIKey)
sqls = append(sqls, dropTableSQLS...)
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "factor_api_key",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "key", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "expires_at", DataType: sqlschema.DataTypeInteger, Nullable: false},
{Name: "last_observed_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "service_account_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("service_account_id"),
ReferencedTableName: sqlschema.TableName("service_account"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "factor_api_key", ColumnNames: []sqlschema.ColumnName{"key"}})
sqls = append(sqls, indexSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "factor_api_key", ColumnNames: []sqlschema.ColumnName{"name", "service_account_id"}})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if len(newKeys) > 0 {
if _, err := tx.NewInsert().Model(&newKeys).Exec(ctx); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *deprecateAPIKey) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,148 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addServiceAccountAuthz struct {
sqlstore sqlstore.SQLStore
}
func NewServiceAccountAuthzactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_service_account_authz"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addServiceAccountAuthz{sqlstore: sqlstore}, nil
})
}
func (migration *addServiceAccountAuthz) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addServiceAccountAuthz) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
type saRoleTuple struct {
ServiceAccountID string
OrgID string
RoleName string
}
rows, err := tx.QueryContext(ctx, `
SELECT sa.id, sa.org_id, r.name
FROM service_account sa
JOIN service_account_role sar ON sar.service_account_id = sa.id
JOIN role r ON r.id = sar.role_id
`)
if err != nil && err != sql.ErrNoRows {
return err
}
defer rows.Close()
tuples := make([]saRoleTuple, 0)
for rows.Next() {
var t saRoleTuple
if err := rows.Scan(&t.ServiceAccountID, &t.OrgID, &t.RoleName); err != nil {
return err
}
tuples = append(tuples, t)
}
for _, t := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + t.OrgID + "/role/" + t.RoleName
saUserID := "organization/" + t.OrgID + "/serviceaccount/" + t.ServiceAccountID
if migration.sqlstore.BunDB().Dialect().Name() == dialect.PG {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount:"+saUserID, "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount:"+saUserID, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount", saUserID, "", "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount", saUserID, "", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addServiceAccountAuthz) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -2,6 +2,7 @@ package jwttokenizer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/golang-jwt/jwt/v5"
)
@@ -9,15 +10,21 @@ var _ jwt.ClaimsValidator = (*Claims)(nil)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"id"`
Email string `json:"email"`
OrgID string `json:"orgId"`
UserID string `json:"id"`
Email string `json:"email"`
Role types.Role `json:"role"`
OrgID string `json:"orgId"`
}
func (c *Claims) Validate() error {
if c.UserID == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "id is required")
}
// The problem is that when the "role" field is missing entirely from the JSON (as opposed to being present but empty), the UnmarshalJSON method for Role isn't called at all.
// The JSON decoder just sets the Role field to its zero value ("").
if c.Role == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "role is required")
}
if c.OrgID == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "orgId is required")

View File

@@ -78,6 +78,7 @@ func (provider *provider) Start(ctx context.Context) error {
func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.Identity, meta map[string]string) (*authtypes.Token, error) {
accessTokenClaims := Claims{
UserID: identity.UserID.String(),
Role: identity.Role,
Email: identity.Email.String(),
OrgID: identity.OrgID.String(),
RegisteredClaims: jwt.RegisteredClaims{
@@ -93,6 +94,7 @@ func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.I
refreshTokenClaims := Claims{
UserID: identity.UserID.String(),
Role: identity.Role,
Email: identity.Email.String(),
OrgID: identity.OrgID.String(),
RegisteredClaims: jwt.RegisteredClaims{
@@ -115,7 +117,17 @@ func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (
return nil, err
}
return authtypes.NewPrincipalUserIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), authtypes.IdentNProviderTokenizer), nil
// check claimed role
identity, err := provider.getOrSetIdentity(ctx, emptyOrgID, valuer.MustNewUUID(claims.UserID))
if err != nil {
return nil, err
}
if identity.Role != claims.Role {
return nil, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "claim role mismatch")
}
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role, authtypes.IdentNProviderTokenizer), nil
}
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
@@ -61,6 +62,7 @@ func TestLastObservedAt_Concurrent(t *testing.T) {
&authtypes.Identity{
UserID: valuer.GenerateUUID(),
OrgID: orgID,
Role: types.RoleAdmin,
Email: valuer.MustNewEmail("test@test.com"),
},
map[string]string{},
@@ -72,6 +74,7 @@ func TestLastObservedAt_Concurrent(t *testing.T) {
&authtypes.Identity{
UserID: valuer.GenerateUUID(),
OrgID: orgID,
Role: types.RoleAdmin,
Email: valuer.MustNewEmail("test@test.com"),
},
map[string]string{},

View File

@@ -3,6 +3,7 @@ package sqltokenizerstore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -46,7 +47,25 @@ func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist", userID)
}
return authtypes.NewPrincipalUserIdentity(userID, user.OrgID, user.Email, authtypes.IdentNProviderTokenizer), nil
userRoles := make([]*authtypes.UserRole, 0)
err = store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&userRoles).
Where("user_id = ?", userID).
Relation("Role").
Scan(ctx)
if err != nil {
return nil, err
}
if len(userRoles) == 0 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no roles found for user with id: %s", userID)
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(userID, user.OrgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
}
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {

View File

@@ -22,22 +22,14 @@ var (
AuthNProviderOIDC = AuthNProvider{valuer.NewString("oidc")}
)
var (
PrincipalUser = Principal{valuer.NewString("user")}
PrincipalServiceAccount = Principal{valuer.NewString("service_account")}
)
type AuthNProvider struct{ valuer.String }
type Principal struct{ valuer.String }
type Identity struct {
UserID valuer.UUID `json:"userId"`
ServiceAccountID valuer.UUID `json:"serviceAccountId"`
Principal Principal `json:"principal"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
UserID valuer.UUID `json:"userId"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
Role types.Role `json:"role"`
}
type CallbackIdentity struct {
@@ -87,37 +79,16 @@ func NewStateFromString(state string) (State, error) {
}, nil
}
func NewIdentity(userID valuer.UUID, serviceAccountID valuer.UUID, principal Principal, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
return &Identity{
UserID: userID,
ServiceAccountID: serviceAccountID,
Principal: principal,
OrgID: orgID,
Email: email,
IdenNProvider: identNProvider,
}
}
func NewPrincipalUserIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role, identNProvider IdentNProvider) *Identity {
return &Identity{
UserID: userID,
Principal: PrincipalUser,
OrgID: orgID,
Email: email,
Role: role,
IdenNProvider: identNProvider,
}
}
func NewPrincipalServiceAccountIdentity(serviceAccountID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
return &Identity{
ServiceAccountID: serviceAccountID,
Principal: PrincipalServiceAccount,
OrgID: orgID,
Email: email,
IdenNProvider: identNProvider,
}
}
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State, groups []string, role string) *CallbackIdentity {
return &CallbackIdentity{
Name: name,
@@ -147,12 +118,11 @@ func (typ *Identity) UnmarshalBinary(data []byte) error {
func (typ *Identity) ToClaims() Claims {
return Claims{
UserID: typ.UserID.String(),
ServiceAccountID: typ.ServiceAccountID.String(),
Principal: typ.Principal.StringValue(),
Email: typ.Email.String(),
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider.StringValue(),
UserID: typ.UserID.String(),
Email: typ.Email.String(),
Role: typ.Role,
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider.StringValue(),
}
}

View File

@@ -3,21 +3,21 @@ package authtypes
import (
"context"
"log/slog"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
)
type claimsKey struct{}
type accessTokenKey struct{}
type apiKeyKey struct{}
type Claims struct {
UserID string
ServiceAccountID string
Principal string
Email string
OrgID string
IdentNProvider string
UserID string
Email string
Role types.Role
OrgID string
IdentNProvider string
}
// NewContextWithClaims attaches individual claims to the context.
@@ -48,42 +48,48 @@ func AccessTokenFromContext(ctx context.Context) (string, error) {
return accessToken, nil
}
func NewContextWithAPIKey(ctx context.Context, apiKey string) context.Context {
return context.WithValue(ctx, apiKeyKey{}, apiKey)
}
func APIKeyFromContext(ctx context.Context) (string, error) {
apiKey, ok := ctx.Value(apiKeyKey{}).(string)
if !ok {
return "", errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
}
return apiKey, nil
}
func (c *Claims) LogValue() slog.Value {
return slog.GroupValue(
slog.String("user_id", c.UserID),
slog.String("service_account_id", c.ServiceAccountID),
slog.String("principal", c.Principal),
slog.String("email", c.Email),
slog.String("role", c.Role.String()),
slog.String("org_id", c.OrgID),
slog.String("identn_provider", c.IdentNProvider),
)
}
func (c *Claims) IsViewer() error {
if slices.Contains([]types.Role{types.RoleViewer, types.RoleEditor, types.RoleAdmin}, c.Role) {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only viewers/editors/admins can access this resource")
}
func (c *Claims) IsEditor() error {
if slices.Contains([]types.Role{types.RoleEditor, types.RoleAdmin}, c.Role) {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only editors/admins can access this resource")
}
func (c *Claims) IsAdmin() error {
if c.Role == types.RoleAdmin {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can access this resource")
}
func (c *Claims) IsSelfAccess(id string) error {
if c.UserID == id {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only the user/admin can access their own resource")
}
func (c *Claims) IdentityID() string {
if c.Principal == PrincipalUser.StringValue() {
return c.UserID
if c.Role == types.RoleAdmin {
return nil
}
return c.ServiceAccountID
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only the user/admin can access their own resource")
}

View File

@@ -284,15 +284,15 @@ func (dashboard *Dashboard) Update(ctx context.Context, updatableDashboard Updat
return nil
}
func (dashboard *Dashboard) CanLockUnlock(isAdmin bool, updatedBy string) error {
if dashboard.CreatedBy != updatedBy && !isAdmin {
func (dashboard *Dashboard) CanLockUnlock(role types.Role, updatedBy string) error {
if dashboard.CreatedBy != updatedBy && role != types.RoleAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (dashboard *Dashboard) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
err := dashboard.CanLockUnlock(isAdmin, updatedBy)
func (dashboard *Dashboard) LockUnlock(lock bool, role types.Role, updatedBy string) error {
err := dashboard.CanLockUnlock(role, updatedBy)
if err != nil {
return err
}

View File

@@ -0,0 +1,6 @@
package exporttypes
type ExportRawDataFormatQueryParam struct {
// Format specifies the output format: "csv" or "jsonl"
Format string `query:"format,default=csv" default:"csv" enum:"csv,jsonl" description:"The output format for the export."`
}

144
pkg/types/factor_api_key.go Normal file
View File

@@ -0,0 +1,144 @@
package types
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var NEVER_EXPIRES = time.Unix(0, 0)
type PostableAPIKey struct {
Name string `json:"name"`
Role Role `json:"role"`
ExpiresInDays int64 `json:"expiresInDays"`
}
type GettableAPIKey struct {
Identifiable
TimeAuditable
UserAuditable
Token string `json:"token"`
Role Role `json:"role"`
Name string `json:"name"`
ExpiresAt int64 `json:"expiresAt"`
LastUsed int64 `json:"lastUsed"`
Revoked bool `json:"revoked"`
UserID string `json:"userId"`
CreatedByUser *User `json:"createdByUser"`
UpdatedByUser *User `json:"updatedByUser"`
}
type OrgUserAPIKey struct {
*Organization `bun:",extend"`
Users []*UserWithAPIKey `bun:"rel:has-many,join:id=org_id"`
}
type UserWithAPIKey struct {
*User `bun:",extend"`
APIKeys []*StorableAPIKeyUser `bun:"rel:has-many,join:id=user_id"`
}
type StorableAPIKeyUser struct {
StorableAPIKey `bun:",extend"`
CreatedByUser *User `json:"createdByUser" bun:"created_by_user,rel:belongs-to,join:created_by=id"`
UpdatedByUser *User `json:"updatedByUser" bun:"updated_by_user,rel:belongs-to,join:updated_by=id"`
}
type StorableAPIKey struct {
bun.BaseModel `bun:"table:factor_api_key"`
Identifiable
TimeAuditable
UserAuditable
Token string `json:"token" bun:"token,type:text,notnull,unique"`
Role Role `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"`
Name string `json:"name" bun:"name,type:text,notnull"`
ExpiresAt time.Time `json:"-" bun:"expires_at,notnull,nullzero,type:timestamptz"`
LastUsed time.Time `json:"-" bun:"last_used,notnull,nullzero,type:timestamptz"`
Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"`
UserID valuer.UUID `json:"userId" bun:"user_id,type:text,notnull"`
}
func NewStorableAPIKey(name string, userID valuer.UUID, role Role, expiresAt int64) (*StorableAPIKey, error) {
// validate
// we allow the APIKey if expiresAt is not set, which means it never expires
if expiresAt < 0 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "expiresAt must be greater than 0")
}
if name == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name cannot be empty")
}
if role == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role cannot be empty")
}
now := time.Now()
// convert expiresAt to unix timestamp from days
// expiresAt = now.Unix() + (expiresAt * 24 * 60 * 60)
expiresAtTime := now.AddDate(0, 0, int(expiresAt))
// if the expiresAt is 0, it means the APIKey never expires
if expiresAt == 0 {
expiresAtTime = NEVER_EXPIRES
}
// Generate a 32-byte random token.
token := make([]byte, 32)
_, err := rand.Read(token)
if err != nil {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to generate token")
}
// Encode the token in base64.
encodedToken := base64.StdEncoding.EncodeToString(token)
return &StorableAPIKey{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
UserAuditable: UserAuditable{
CreatedBy: userID.String(),
UpdatedBy: userID.String(),
},
Token: encodedToken,
Name: name,
Role: role,
UserID: userID,
ExpiresAt: expiresAtTime,
LastUsed: now,
Revoked: false,
}, nil
}
func NewGettableAPIKeyFromStorableAPIKey(storableAPIKey *StorableAPIKeyUser) *GettableAPIKey {
lastUsed := storableAPIKey.LastUsed.Unix()
if storableAPIKey.LastUsed == storableAPIKey.CreatedAt {
lastUsed = 0
}
return &GettableAPIKey{
Identifiable: storableAPIKey.Identifiable,
TimeAuditable: storableAPIKey.TimeAuditable,
UserAuditable: storableAPIKey.UserAuditable,
Token: storableAPIKey.Token,
Role: storableAPIKey.Role,
Name: storableAPIKey.Name,
ExpiresAt: storableAPIKey.ExpiresAt.Unix(),
LastUsed: lastUsed,
Revoked: storableAPIKey.Revoked,
UserID: storableAPIKey.UserID.String(),
CreatedByUser: storableAPIKey.CreatedByUser,
UpdatedByUser: storableAPIKey.UpdatedByUser,
}
}

View File

@@ -393,6 +393,77 @@ func (r *QueryRangeRequest) HasOrderSpecified() bool {
return false
}
// UseDefaultOrderBy applies UseDefaultOrderByForListQuery to every query in the
// composite query when the request type is a list query (raw, raw_stream, trace).
func (r *QueryRangeRequest) UseDefaultOrderBy() {
// Based on the request type, handle default order-bys
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace:
for idx := range r.CompositeQuery.Queries {
r.CompositeQuery.Queries[idx].UseDefaultOrderByForListQuery()
}
}
}
// UseDefaultOrderByForListQuery applies a default timestamp-descending order
// for list/raw queries when no explicit order is specified. This is intended
// for raw data listing endpoints (e.g. export, list views) where a sensible
// default sort is needed, not for aggregation or timeseries queries.
func (q *QueryEnvelope) UseDefaultOrderByForListQuery() {
if len(q.GetOrder()) > 0 {
return
}
switch q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation],
QueryBuilderTraceOperator:
q.SetOrder(
[]OrderBy{
{
Key: OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "timestamp",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
},
Direction: OrderDirectionDesc,
},
},
)
case QueryBuilderQuery[LogAggregation]:
q.SetOrder(
[]OrderBy{
{
Key: OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "timestamp",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
},
Direction: OrderDirectionDesc,
},
{
Key: OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "id",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
Direction: OrderDirectionDesc,
},
},
)
}
}
func (r *QueryRangeRequest) FuncsForQuery(name string) []Function {
funcs := []Function{}
for _, query := range r.CompositeQuery.Queries {
@@ -437,6 +508,16 @@ func (r *QueryRangeRequest) IsAnomalyRequest() (*QueryBuilderQuery[MetricAggrega
return &q, hasAnomaly
}
func (r *QueryRangeRequest) TraceOperatorQueryIndex() int {
for idx, query := range r.CompositeQuery.Queries {
switch query.Spec.(type) {
case QueryBuilderTraceOperator:
return idx
}
}
return -1
}
// We do not support fill gaps for these queries. Maybe support in future?
func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
for _, query := range r.CompositeQuery.Queries {

View File

@@ -0,0 +1,379 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
// GetExpression returns the expression string.
func (q *QueryEnvelope) GetExpression() string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Expression
case QueryBuilderFormula:
return spec.Expression
}
return ""
}
// GetReturnSpansFrom returns the return-spans-from value.
func (q *QueryEnvelope) GetReturnSpansFrom() string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.ReturnSpansFrom
}
return ""
}
// GetSignal returns the signal.
func (q *QueryEnvelope) GetSignal() telemetrytypes.Signal {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.Signal
case QueryBuilderQuery[LogAggregation]:
return spec.Signal
case QueryBuilderQuery[MetricAggregation]:
return spec.Signal
}
return telemetrytypes.SignalUnspecified
}
// GetSource returns the source.
func (q *QueryEnvelope) GetSource() telemetrytypes.Source {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.Source
case QueryBuilderQuery[LogAggregation]:
return spec.Source
case QueryBuilderQuery[MetricAggregation]:
return spec.Source
}
return telemetrytypes.SourceUnspecified
}
// GetQuery returns the raw query string.
func (q *QueryEnvelope) GetQuery() string {
switch spec := q.Spec.(type) {
case PromQuery:
return spec.Query
case ClickHouseQuery:
return spec.Query
}
return ""
}
// GetStats returns the PromQL stats flag.
func (q *QueryEnvelope) GetStats() bool {
switch spec := q.Spec.(type) {
case PromQuery:
return spec.Stats
}
return false
}
// GetLeft returns the left query reference of a join.
func (q *QueryEnvelope) GetLeft() QueryRef {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
return spec.Left
}
return QueryRef{}
}
// GetRight returns the right query reference of a join.
func (q *QueryEnvelope) GetRight() QueryRef {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
return spec.Right
}
return QueryRef{}
}
// GetJoinType returns the join type.
func (q *QueryEnvelope) GetJoinType() JoinType {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
return spec.Type
}
return JoinType{}
}
// GetOn returns the join ON condition.
func (q *QueryEnvelope) GetOn() string {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
return spec.On
}
return ""
}
// GetQueryName returns the name of the spec.
func (q *QueryEnvelope) GetQueryName() string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Name
case QueryBuilderQuery[TraceAggregation]:
return spec.Name
case QueryBuilderQuery[LogAggregation]:
return spec.Name
case QueryBuilderQuery[MetricAggregation]:
return spec.Name
case QueryBuilderFormula:
return spec.Name
case QueryBuilderJoin:
return spec.Name
case PromQuery:
return spec.Name
case ClickHouseQuery:
return spec.Name
}
return ""
}
// IsDisabled returns whether the spec is disabled.
func (q *QueryEnvelope) IsDisabled() bool {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Disabled
case QueryBuilderQuery[TraceAggregation]:
return spec.Disabled
case QueryBuilderQuery[LogAggregation]:
return spec.Disabled
case QueryBuilderQuery[MetricAggregation]:
return spec.Disabled
case QueryBuilderFormula:
return spec.Disabled
case QueryBuilderJoin:
return spec.Disabled
case PromQuery:
return spec.Disabled
case ClickHouseQuery:
return spec.Disabled
}
return false
}
// GetLimit returns the row limit.
func (q *QueryEnvelope) GetLimit() int {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Limit
case QueryBuilderQuery[TraceAggregation]:
return spec.Limit
case QueryBuilderQuery[LogAggregation]:
return spec.Limit
case QueryBuilderQuery[MetricAggregation]:
return spec.Limit
case QueryBuilderFormula:
return spec.Limit
case QueryBuilderJoin:
return spec.Limit
}
return 0
}
// GetOffset returns the row offset.
func (q *QueryEnvelope) GetOffset() int {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Offset
case QueryBuilderQuery[TraceAggregation]:
return spec.Offset
case QueryBuilderQuery[LogAggregation]:
return spec.Offset
case QueryBuilderQuery[MetricAggregation]:
return spec.Offset
}
return 0
}
// GetType returns the QueryType of the envelope.
func (q *QueryEnvelope) GetType() QueryType {
return q.Type
}
// GetOrder returns the order-by clauses.
func (q *QueryEnvelope) GetOrder() []OrderBy {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Order
case QueryBuilderQuery[TraceAggregation]:
return spec.Order
case QueryBuilderQuery[LogAggregation]:
return spec.Order
case QueryBuilderQuery[MetricAggregation]:
return spec.Order
case QueryBuilderFormula:
return spec.Order
case QueryBuilderJoin:
return spec.Order
}
return nil
}
// GetGroupBy returns the group-by keys.
func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.GroupBy
case QueryBuilderQuery[TraceAggregation]:
return spec.GroupBy
case QueryBuilderQuery[LogAggregation]:
return spec.GroupBy
case QueryBuilderQuery[MetricAggregation]:
return spec.GroupBy
case QueryBuilderJoin:
return spec.GroupBy
}
return nil
}
// GetFilter returns the filter.
func (q *QueryEnvelope) GetFilter() *Filter {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Filter
case QueryBuilderQuery[TraceAggregation]:
return spec.Filter
case QueryBuilderQuery[LogAggregation]:
return spec.Filter
case QueryBuilderQuery[MetricAggregation]:
return spec.Filter
case QueryBuilderJoin:
return spec.Filter
}
return nil
}
// GetHaving returns the having clause.
func (q *QueryEnvelope) GetHaving() *Having {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Having
case QueryBuilderQuery[TraceAggregation]:
return spec.Having
case QueryBuilderQuery[LogAggregation]:
return spec.Having
case QueryBuilderQuery[MetricAggregation]:
return spec.Having
case QueryBuilderFormula:
return spec.Having
case QueryBuilderJoin:
return spec.Having
}
return nil
}
// GetFunctions returns the post-processing functions.
func (q *QueryEnvelope) GetFunctions() []Function {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Functions
case QueryBuilderQuery[TraceAggregation]:
return spec.Functions
case QueryBuilderQuery[LogAggregation]:
return spec.Functions
case QueryBuilderQuery[MetricAggregation]:
return spec.Functions
case QueryBuilderFormula:
return spec.Functions
case QueryBuilderJoin:
return spec.Functions
}
return nil
}
// GetSelectFields returns the selected fields.
func (q *QueryEnvelope) GetSelectFields() []telemetrytypes.TelemetryFieldKey {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.SelectFields
case QueryBuilderQuery[TraceAggregation]:
return spec.SelectFields
case QueryBuilderQuery[LogAggregation]:
return spec.SelectFields
case QueryBuilderQuery[MetricAggregation]:
return spec.SelectFields
case QueryBuilderJoin:
return spec.SelectFields
}
return nil
}
// GetLegend returns the legend label.
func (q *QueryEnvelope) GetLegend() string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Legend
case QueryBuilderQuery[TraceAggregation]:
return spec.Legend
case QueryBuilderQuery[LogAggregation]:
return spec.Legend
case QueryBuilderQuery[MetricAggregation]:
return spec.Legend
case QueryBuilderFormula:
return spec.Legend
case PromQuery:
return spec.Legend
case ClickHouseQuery:
return spec.Legend
}
return ""
}
// GetCursor returns the pagination cursor.
func (q *QueryEnvelope) GetCursor() string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.Cursor
case QueryBuilderQuery[TraceAggregation]:
return spec.Cursor
case QueryBuilderQuery[LogAggregation]:
return spec.Cursor
case QueryBuilderQuery[MetricAggregation]:
return spec.Cursor
}
return ""
}
// GetStepInterval returns the step interval.
func (q *QueryEnvelope) GetStepInterval() Step {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
return spec.StepInterval
case QueryBuilderQuery[TraceAggregation]:
return spec.StepInterval
case QueryBuilderQuery[LogAggregation]:
return spec.StepInterval
case QueryBuilderQuery[MetricAggregation]:
return spec.StepInterval
case PromQuery:
return spec.Step
}
return Step{}
}
// GetSecondaryAggregations returns the secondary aggregations.
func (q *QueryEnvelope) GetSecondaryAggregations() []SecondaryAggregation {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.SecondaryAggregations
case QueryBuilderQuery[LogAggregation]:
return spec.SecondaryAggregations
case QueryBuilderQuery[MetricAggregation]:
return spec.SecondaryAggregations
case QueryBuilderJoin:
return spec.SecondaryAggregations
}
return nil
}
// GetLimitBy returns the limit-by configuration.
func (q *QueryEnvelope) GetLimitBy() *LimitBy {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.LimitBy
case QueryBuilderQuery[LogAggregation]:
return spec.LimitBy
case QueryBuilderQuery[MetricAggregation]:
return spec.LimitBy
}
return nil
}

View File

@@ -0,0 +1,452 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
// SetExpression sets the expression string of the spec, if applicable.
func (q *QueryEnvelope) SetExpression(expression string) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Expression = expression
q.Spec = spec
case QueryBuilderFormula:
spec.Expression = expression
q.Spec = spec
}
}
// SetReturnSpansFrom sets the return-spans-from value, if applicable.
func (q *QueryEnvelope) SetReturnSpansFrom(returnSpansFrom string) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.ReturnSpansFrom = returnSpansFrom
q.Spec = spec
}
}
// SetSignal sets the signal of the spec, if applicable.
func (q *QueryEnvelope) SetSignal(signal telemetrytypes.Signal) {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
spec.Signal = signal
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Signal = signal
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Signal = signal
q.Spec = spec
}
}
// SetSource sets the source of the spec, if applicable.
func (q *QueryEnvelope) SetSource(source telemetrytypes.Source) {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
spec.Source = source
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Source = source
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Source = source
q.Spec = spec
}
}
// SetQuery sets the raw query string of the spec, if applicable.
func (q *QueryEnvelope) SetQuery(query string) {
switch spec := q.Spec.(type) {
case PromQuery:
spec.Query = query
q.Spec = spec
case ClickHouseQuery:
spec.Query = query
q.Spec = spec
}
}
// SetStats sets the PromQL stats flag, if applicable.
func (q *QueryEnvelope) SetStats(stats bool) {
switch spec := q.Spec.(type) {
case PromQuery:
spec.Stats = stats
q.Spec = spec
}
}
// SetLeft sets the left query reference of a join, if applicable.
func (q *QueryEnvelope) SetLeft(left QueryRef) {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
spec.Left = left
q.Spec = spec
}
}
// SetRight sets the right query reference of a join, if applicable.
func (q *QueryEnvelope) SetRight(right QueryRef) {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
spec.Right = right
q.Spec = spec
}
}
// SetJoinType sets the join type, if applicable.
func (q *QueryEnvelope) SetJoinType(joinType JoinType) {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
spec.Type = joinType
q.Spec = spec
}
}
// SetOn sets the join ON condition, if applicable.
func (q *QueryEnvelope) SetOn(on string) {
switch spec := q.Spec.(type) {
case QueryBuilderJoin:
spec.On = on
q.Spec = spec
}
}
// SetQueryName sets the name of the spec, if applicable.
func (q *QueryEnvelope) SetQueryName(name string) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Name = name
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Name = name
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Name = name
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Name = name
q.Spec = spec
case QueryBuilderFormula:
spec.Name = name
q.Spec = spec
case QueryBuilderJoin:
spec.Name = name
q.Spec = spec
case PromQuery:
spec.Name = name
q.Spec = spec
case ClickHouseQuery:
spec.Name = name
q.Spec = spec
}
}
// SetDisabled sets the disabled flag of the spec, if applicable.
func (q *QueryEnvelope) SetDisabled(disabled bool) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Disabled = disabled
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Disabled = disabled
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Disabled = disabled
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Disabled = disabled
q.Spec = spec
case QueryBuilderFormula:
spec.Disabled = disabled
q.Spec = spec
case QueryBuilderJoin:
spec.Disabled = disabled
q.Spec = spec
case PromQuery:
spec.Disabled = disabled
q.Spec = spec
case ClickHouseQuery:
spec.Disabled = disabled
q.Spec = spec
}
}
// SetLimit sets the row limit of the spec, if applicable.
func (q *QueryEnvelope) SetLimit(limit int) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Limit = limit
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Limit = limit
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Limit = limit
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Limit = limit
q.Spec = spec
case QueryBuilderFormula:
spec.Limit = limit
q.Spec = spec
case QueryBuilderJoin:
spec.Limit = limit
q.Spec = spec
}
}
// SetOffset sets the row offset of the spec, if applicable.
func (q *QueryEnvelope) SetOffset(offset int) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Offset = offset
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Offset = offset
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Offset = offset
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Offset = offset
q.Spec = spec
}
}
// SetType sets the QueryType of the envelope.
func (q *QueryEnvelope) SetType(t QueryType) {
q.Type = t
}
// SetOrder sets the order-by clauses of the spec, if applicable.
func (q *QueryEnvelope) SetOrder(order []OrderBy) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Order = order
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Order = order
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Order = order
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Order = order
q.Spec = spec
case QueryBuilderFormula:
spec.Order = order
q.Spec = spec
case QueryBuilderJoin:
spec.Order = order
q.Spec = spec
}
}
// SetGroupBy sets the group-by keys of the spec, if applicable.
func (q *QueryEnvelope) SetGroupBy(groupBy []GroupByKey) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.GroupBy = groupBy
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.GroupBy = groupBy
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.GroupBy = groupBy
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.GroupBy = groupBy
q.Spec = spec
case QueryBuilderJoin:
spec.GroupBy = groupBy
q.Spec = spec
}
}
// SetFilter sets the filter of the spec, if applicable.
func (q *QueryEnvelope) SetFilter(filter *Filter) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Filter = filter
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Filter = filter
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Filter = filter
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Filter = filter
q.Spec = spec
case QueryBuilderJoin:
spec.Filter = filter
q.Spec = spec
}
}
// SetHaving sets the having clause of the spec, if applicable.
func (q *QueryEnvelope) SetHaving(having *Having) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Having = having
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Having = having
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Having = having
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Having = having
q.Spec = spec
case QueryBuilderFormula:
spec.Having = having
q.Spec = spec
case QueryBuilderJoin:
spec.Having = having
q.Spec = spec
}
}
// SetFunctions sets the post-processing functions of the spec, if applicable.
func (q *QueryEnvelope) SetFunctions(functions []Function) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Functions = functions
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Functions = functions
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Functions = functions
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Functions = functions
q.Spec = spec
case QueryBuilderFormula:
spec.Functions = functions
q.Spec = spec
case QueryBuilderJoin:
spec.Functions = functions
q.Spec = spec
}
}
// SetSelectFields sets the selected fields of the spec, if applicable.
func (q *QueryEnvelope) SetSelectFields(fields []telemetrytypes.TelemetryFieldKey) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.SelectFields = fields
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.SelectFields = fields
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.SelectFields = fields
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.SelectFields = fields
q.Spec = spec
case QueryBuilderJoin:
spec.SelectFields = fields
q.Spec = spec
}
}
// SetLegend sets the legend label of the spec, if applicable.
func (q *QueryEnvelope) SetLegend(legend string) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Legend = legend
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Legend = legend
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Legend = legend
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Legend = legend
q.Spec = spec
case QueryBuilderFormula:
spec.Legend = legend
q.Spec = spec
case PromQuery:
spec.Legend = legend
q.Spec = spec
case ClickHouseQuery:
spec.Legend = legend
q.Spec = spec
}
}
// SetCursor sets the pagination cursor of the spec, if applicable.
func (q *QueryEnvelope) SetCursor(cursor string) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.Cursor = cursor
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.Cursor = cursor
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.Cursor = cursor
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.Cursor = cursor
q.Spec = spec
}
}
// SetStepInterval sets the step interval of the spec, if applicable.
func (q *QueryEnvelope) SetStepInterval(step Step) {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
spec.StepInterval = step
q.Spec = spec
case QueryBuilderQuery[TraceAggregation]:
spec.StepInterval = step
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.StepInterval = step
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.StepInterval = step
q.Spec = spec
case PromQuery:
spec.Step = step
q.Spec = spec
}
}
// SetSecondaryAggregations sets the secondary aggregations of the spec, if applicable.
func (q *QueryEnvelope) SetSecondaryAggregations(secondaryAggregations []SecondaryAggregation) {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
spec.SecondaryAggregations = secondaryAggregations
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.SecondaryAggregations = secondaryAggregations
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.SecondaryAggregations = secondaryAggregations
q.Spec = spec
case QueryBuilderJoin:
spec.SecondaryAggregations = secondaryAggregations
q.Spec = spec
}
}
// SetLimitBy sets the limit-by configuration of the spec, if applicable.
func (q *QueryEnvelope) SetLimitBy(limitBy *LimitBy) {
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
spec.LimitBy = limitBy
q.Spec = spec
case QueryBuilderQuery[LogAggregation]:
spec.LimitBy = limitBy
q.Spec = spec
case QueryBuilderQuery[MetricAggregation]:
spec.LimitBy = limitBy
q.Spec = spec
}
}

View File

@@ -10,55 +10,9 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// queryName returns the name from any query envelope spec type.
func (e QueryEnvelope) queryName() string {
switch spec := e.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.Name
case QueryBuilderQuery[LogAggregation]:
return spec.Name
case QueryBuilderQuery[MetricAggregation]:
return spec.Name
case QueryBuilderFormula:
return spec.Name
case QueryBuilderTraceOperator:
return spec.Name
case QueryBuilderJoin:
return spec.Name
case PromQuery:
return spec.Name
case ClickHouseQuery:
return spec.Name
}
return ""
}
// isDisabled returns the disabled status from any query envelope spec type.
func (e QueryEnvelope) isDisabled() bool {
switch spec := e.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.Disabled
case QueryBuilderQuery[LogAggregation]:
return spec.Disabled
case QueryBuilderQuery[MetricAggregation]:
return spec.Disabled
case QueryBuilderFormula:
return spec.Disabled
case QueryBuilderTraceOperator:
return spec.Disabled
case QueryBuilderJoin:
return spec.Disabled
case PromQuery:
return spec.Disabled
case ClickHouseQuery:
return spec.Disabled
}
return false
}
// getQueryIdentifier returns a friendly identifier for a query based on its type and name/content
func getQueryIdentifier(envelope QueryEnvelope, index int) string {
name := envelope.queryName()
name := envelope.GetQueryName()
var typeLabel string
switch envelope.Type {
@@ -89,50 +43,115 @@ const (
MaxQueryLimit = 10000
)
// Validate performs preliminary validation on QueryBuilderQuery
func (q *QueryBuilderQuery[T]) Validate(requestType RequestType) error {
// Validate signal
// ValidationOption is a functional option for configuring validation behaviour.
type ValidationOption func(*validationConfig)
type validationConfig struct {
skipLimitOffsetValidation bool
skipAggregationValidation bool
skipHavingValidation bool
skipAggregationOrderBy bool
skipSelectFieldValidation bool
skipGroupByValidation bool
}
func applyValidationOptions(opts []ValidationOption) validationConfig {
cfg := validationConfig{}
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// SkipLimitOffsetValidation returns a ValidationOption that skips the limit and offset range checks.
// Use this when the caller has already validated limits and offsets with different constraints.
func WithSkipLimitOffsetValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipLimitOffsetValidation = true
}
}
// SkipAggregationValidation skips aggregation validation.
// Used for raw/trace request types where aggregations are not required.
func WithSkipAggregationValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipAggregationValidation = true
}
}
// SkipHavingValidation skips having-clause validation.
// Used for raw/trace request types where having clauses do not apply.
func WithSkipHavingValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipHavingValidation = true
}
}
// SkipAggregationOrderBy skips the aggregation-specific order-by key validation.
// Used for raw/trace request types where order-by keys are not restricted to group-by or aggregation keys.
func WithSkipAggregationOrderBy() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipAggregationOrderBy = true
}
}
// SkipSelectFieldValidation skips select-field validation.
// Used for aggregation request types where select fields do not apply.
func WithSkipSelectFieldValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipSelectFieldValidation = true
}
}
// SkipGroupByValidation skips group-by validation.
// Used for raw/trace request types where group-by does not apply.
func WithSkipGroupByValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.skipGroupByValidation = true
}
}
// Validate performs preliminary validation on QueryBuilderQuery.
func (q *QueryBuilderQuery[T]) Validate(opts ...ValidationOption) error {
cfg := applyValidationOptions(opts)
if err := q.validateSignal(); err != nil {
return err
}
if err := q.validateAggregations(requestType); err != nil {
if err := q.validateAggregations(cfg); err != nil {
return err
}
if err := q.validateGroupBy(requestType); err != nil {
if err := q.validateGroupBy(cfg); err != nil {
return err
}
// Validate limit and pagination
if err := q.validateLimitAndPagination(); err != nil {
if err := q.validateLimitAndPagination(cfg); err != nil {
return err
}
// Validate functions
if err := q.validateFunctions(); err != nil {
return err
}
// Validate secondary aggregations
if err := q.validateSecondaryAggregations(); err != nil {
return err
}
if err := q.validateOrderBy(requestType); err != nil {
if err := q.validateOrderBy(cfg); err != nil {
return err
}
if err := q.validateSelectFields(requestType); err != nil {
if err := q.validateSelectFields(cfg); err != nil {
return err
}
return nil
}
func (q *QueryBuilderQuery[T]) validateSelectFields(requestType RequestType) error {
// selectFields don't apply to aggregation queries, skip validation
if requestType.IsAggregation() {
func (q *QueryBuilderQuery[T]) validateSelectFields(cfg validationConfig) error {
if cfg.skipSelectFieldValidation {
return nil
}
@@ -148,9 +167,8 @@ func (q *QueryBuilderQuery[T]) validateSelectFields(requestType RequestType) err
return nil
}
func (q *QueryBuilderQuery[T]) validateGroupBy(requestType RequestType) error {
// groupBy doesn't apply to non-aggregation queries, skip validation
if !requestType.IsAggregation() {
func (q *QueryBuilderQuery[T]) validateGroupBy(cfg validationConfig) error {
if cfg.skipGroupByValidation {
return nil
}
for idx, item := range q.GroupBy {
@@ -183,9 +201,8 @@ func (q *QueryBuilderQuery[T]) validateSignal() error {
}
}
func (q *QueryBuilderQuery[T]) validateAggregations(requestType RequestType) error {
// aggregations don't apply to non-aggregation queries, skip validation
if !requestType.IsAggregation() {
func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error {
if cfg.skipAggregationValidation {
return nil
}
@@ -272,8 +289,11 @@ func (q *QueryBuilderQuery[T]) validateAggregations(requestType RequestType) err
return nil
}
func (q *QueryBuilderQuery[T]) validateLimitAndPagination() error {
// Validate limit
func (q *QueryBuilderQuery[T]) validateLimitAndPagination(cfg validationConfig) error {
if cfg.skipLimitOffsetValidation {
return nil
}
if q.Limit < 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -336,7 +356,7 @@ func (q *QueryBuilderQuery[T]) validateSecondaryAggregations() error {
return nil
}
func (q *QueryBuilderQuery[T]) validateOrderBy(requestType RequestType) error {
func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
for i, order := range q.Order {
// Direction validation is handled by the OrderDirection type
if order.Direction != OrderDirectionAsc && order.Direction != OrderDirectionDesc {
@@ -355,8 +375,7 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(requestType RequestType) error {
}
}
// aggregation-specific order key validation only applies to aggregation queries
if requestType.IsAggregation() {
if !cfg.skipAggregationOrderBy {
return q.validateOrderByForAggregation()
}
@@ -438,8 +457,8 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
return nil
}
// ValidateQueryRangeRequest validates the entire query range request
func (r *QueryRangeRequest) Validate() error {
// Validate validates the entire query range request.
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// Validate time range
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return errors.NewInvalidInputf(
@@ -450,8 +469,8 @@ func (r *QueryRangeRequest) Validate() error {
// Validate request type
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeTrace:
// Valid request types
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -463,7 +482,7 @@ func (r *QueryRangeRequest) Validate() error {
}
// Validate composite query
if err := r.validateCompositeQuery(); err != nil {
if err := r.CompositeQuery.Validate(opts...); err != nil {
return err
}
@@ -478,7 +497,7 @@ func (r *QueryRangeRequest) Validate() error {
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
for _, envelope := range r.CompositeQuery.Queries {
if !envelope.isDisabled() {
if !envelope.IsDisabled() {
return nil
}
}
@@ -489,12 +508,8 @@ func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
)
}
func (r *QueryRangeRequest) validateCompositeQuery() error {
return r.CompositeQuery.Validate(r.RequestType)
}
// Validate performs validation on CompositeQuery
func (c *CompositeQuery) Validate(requestType RequestType) error {
func (c *CompositeQuery) Validate(opts ...ValidationOption) error {
if len(c.Queries) == 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -506,14 +521,14 @@ func (c *CompositeQuery) Validate(requestType RequestType) error {
queryNames := make(map[string]bool)
for i, envelope := range c.Queries {
if err := validateQueryEnvelope(envelope, requestType); err != nil {
if err := validateQueryEnvelope(envelope, opts...); err != nil {
queryId := getQueryIdentifier(envelope, i)
return wrapValidationError(err, queryId, "invalid %s: %s")
}
// Check name uniqueness for builder queries
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
name := envelope.queryName()
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
return errors.NewInvalidInputf(
@@ -530,16 +545,16 @@ func (c *CompositeQuery) Validate(requestType RequestType) error {
return nil
}
func validateQueryEnvelope(envelope QueryEnvelope, requestType RequestType) error {
func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) error {
switch envelope.Type {
case QueryTypeBuilder, QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
return spec.Validate(requestType)
return spec.Validate(opts...)
case QueryBuilderQuery[LogAggregation]:
return spec.Validate(requestType)
return spec.Validate(opts...)
case QueryBuilderQuery[MetricAggregation]:
return spec.Validate(requestType)
return spec.Validate(opts...)
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -625,3 +640,14 @@ func validateQueryEnvelope(envelope QueryEnvelope, requestType RequestType) erro
)
}
}
func GetValidationOptions(requestType RequestType) []ValidationOption {
switch requestType {
case RequestTypeTimeSeries, RequestTypeScalar:
return []ValidationOption{WithSkipSelectFieldValidation()}
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace:
return []ValidationOption{WithSkipAggregationValidation(), WithSkipHavingValidation(), WithSkipAggregationOrderBy(), WithSkipGroupByValidation()}
default:
return []ValidationOption{}
}
}

View File

@@ -743,7 +743,7 @@ func TestValidateQueryEnvelope(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateQueryEnvelope(tt.envelope, tt.requestType)
err := validateQueryEnvelope(tt.envelope)
if tt.wantErr {
if err == nil {
t.Errorf("validateQueryEnvelope() expected error but got none")
@@ -816,7 +816,7 @@ func TestQueryEnvelope_Helpers(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.envelope.queryName()
got := tt.envelope.GetQueryName()
if got != tt.want {
t.Errorf("queryName() = %q, want %q", got, tt.want)
}
@@ -868,7 +868,7 @@ func TestQueryEnvelope_Helpers(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.envelope.isDisabled()
got := tt.envelope.IsDisabled()
if got != tt.want {
t.Errorf("isDisabled() = %v, want %v", got, tt.want)
}
@@ -1107,7 +1107,7 @@ func TestQueryRangeRequest_ValidateOrderByForAggregation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.query.Validate(RequestTypeTimeSeries)
err := tt.query.Validate(GetValidationOptions(RequestTypeTimeSeries)...)
if tt.wantErr {
if err == nil {
t.Errorf("validateOrderByForAggregation() expected error but got none")
@@ -1161,7 +1161,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}},
},
}
err := query.Validate(RequestTypeRaw)
err := query.Validate(GetValidationOptions(RequestTypeRaw)...)
if err != nil {
t.Errorf("expected no error for groupBy with raw request type, got: %v", err)
}
@@ -1178,7 +1178,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}},
},
}
err := query.Validate(RequestTypeTimeSeries)
err := query.Validate(GetValidationOptions(RequestTypeTimeSeries)...)
if err == nil {
t.Errorf("expected error for empty groupBy key with timeseries request type")
}
@@ -1190,7 +1190,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Having: &Having{Expression: "count() > 10"},
}
err := query.Validate(RequestTypeRaw)
err := query.Validate(GetValidationOptions(RequestTypeRaw)...)
if err != nil {
t.Errorf("expected no error for having with raw request type, got: %v", err)
}
@@ -1202,7 +1202,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
Signal: telemetrytypes.SignalTraces,
Having: &Having{Expression: "count() > 10"},
}
err := query.Validate(RequestTypeTrace)
err := query.Validate(GetValidationOptions(RequestTypeTrace)...)
if err != nil {
t.Errorf("expected no error for having with trace request type, got: %v", err)
}
@@ -1216,7 +1216,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
{Expression: "count()"},
},
}
err := query.Validate(RequestTypeRaw)
err := query.Validate(GetValidationOptions(RequestTypeRaw)...)
if err != nil {
t.Errorf("expected no error for aggregations with raw request type, got: %v", err)
}
@@ -1230,7 +1230,7 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
{Expression: "count()"},
},
}
err := query.Validate(RequestTypeRawStream)
err := query.Validate(GetValidationOptions(RequestTypeRawStream)...)
if err != nil {
t.Errorf("expected no error for aggregations with raw_stream request type, got: %v", err)
}
@@ -1248,12 +1248,12 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
},
}
// Should error for raw (selectFields are validated)
err := query.Validate(RequestTypeRaw)
err := query.Validate(GetValidationOptions(RequestTypeRaw)...)
if err == nil {
t.Errorf("expected error for isRoot in selectFields with raw request type")
}
// Should pass for timeseries (selectFields skipped)
err = query.Validate(RequestTypeTimeSeries)
err = query.Validate(GetValidationOptions(RequestTypeTimeSeries)...)
if err != nil {
t.Errorf("expected no error for isRoot in selectFields with timeseries request type, got: %v", err)
}

View File

@@ -11,9 +11,9 @@ import (
)
var (
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("api_key_invalid_input")
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
)
@@ -124,15 +124,10 @@ func NewGettableFactorAPIKeyWithKey(id valuer.UUID, key string) *GettableFactorA
}
}
func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) error {
if expiresAt != 0 && time.Now().After(time.Unix(int64(expiresAt), 0)) {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "cannot set api key expiry in the past")
}
func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) {
apiKey.Name = name
apiKey.ExpiresAt = expiresAt
apiKey.UpdatedAt = time.Now()
return nil
}
func (apiKey *FactorAPIKey) IsExpired() error {
@@ -189,18 +184,3 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
*key = UpdatableFactorAPIKey(temp)
return nil
}
func (key FactorAPIKey) MarshalBinary() ([]byte, error) {
return json.Marshal(key)
}
func (key *FactorAPIKey) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, key)
}
func (key *FactorAPIKey) Traits() map[string]any {
return map[string]any{
"name": key.Name,
"expires_at": key.ExpiresAt,
}
}

View File

@@ -4,7 +4,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"time"
@@ -16,7 +15,6 @@ import (
)
var (
ErrCodeServiceAccountInvalidConfig = errors.MustNewCode("service_account_invalid_config")
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
@@ -30,10 +28,6 @@ var (
ValidStatus = []valuer.String{StatusActive, StatusDisabled}
)
var (
serviceAccountEmailDomain = valuer.NewString("signozserviceaccount.io")
)
var (
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
)
@@ -43,37 +37,41 @@ type StorableServiceAccount struct {
types.Identifiable
types.TimeAuditable
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
DeletedAt time.Time `bun:"deleted_at"`
}
type ServiceAccount struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Status valuer.String `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Status valuer.String `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
DeletedAt time.Time `json:"deletedAt" required:"true"`
}
type PostableServiceAccount struct {
Name string `json:"name" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccount struct {
Name string `json:"name" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccountStatus struct {
Status valuer.String `json:"status" required:"true"`
}
func NewServiceAccount(name string, prefix string, roles []string, status valuer.String, orgID valuer.UUID) *ServiceAccount {
func NewServiceAccount(name string, email valuer.Email, roles []string, status valuer.String, orgID valuer.UUID) *ServiceAccount {
return &ServiceAccount{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -82,11 +80,12 @@ func NewServiceAccount(name string, prefix string, roles []string, status valuer
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Email: valuer.MustNewEmail(fmt.Sprintf("%s@%s.%s", name, prefix, serviceAccountEmailDomain)),
Roles: roles,
Status: status,
OrgID: orgID,
Name: name,
Email: email,
Roles: roles,
Status: status,
OrgID: orgID,
DeletedAt: time.Time{},
}
}
@@ -99,6 +98,7 @@ func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccou
Roles: roles,
Status: storableServiceAccount.Status,
OrgID: valuer.MustNewUUID(storableServiceAccount.OrgID),
DeletedAt: storableServiceAccount.DeletedAt,
}
}
@@ -135,15 +135,17 @@ func NewStorableServiceAccount(serviceAccount *ServiceAccount) *StorableServiceA
Email: serviceAccount.Email.String(),
Status: serviceAccount.Status,
OrgID: serviceAccount.OrgID.String(),
DeletedAt: serviceAccount.DeletedAt,
}
}
func (sa *ServiceAccount) Update(name string, roles []string) error {
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) error {
if err := sa.ErrIfDisabled(); err != nil {
return err
}
sa.Name = name
sa.Email = email
sa.Roles = roles
sa.UpdatedAt = time.Now()
return nil
@@ -156,6 +158,7 @@ func (sa *ServiceAccount) UpdateStatus(status valuer.String) error {
sa.Status = status
sa.UpdatedAt = time.Now()
sa.DeletedAt = time.Now()
return nil
}
@@ -172,10 +175,6 @@ func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*Facto
return nil, err
}
if expiresAt != 0 && time.Now().After(time.Unix(int64(expiresAt), 0)) {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "cannot set api key expiry in the past")
}
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
@@ -285,22 +284,3 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
*sa = UpdatableServiceAccountStatus(temp)
return nil
}
func (sa *StorableServiceAccount) ToIdentity() *authtypes.Identity {
return &authtypes.Identity{
ServiceAccountID: sa.ID,
Principal: authtypes.PrincipalServiceAccount,
OrgID: valuer.MustNewUUID(sa.OrgID),
IdenNProvider: authtypes.IdentNProviderAPIKey,
Email: valuer.MustNewEmail(sa.Email),
}
}
func (sa *ServiceAccount) Traits() map[string]any {
return map[string]any{
"name": sa.Name,
"email": sa.Email.String(),
"created_at": sa.CreatedAt,
"status": sa.Status.StringValue(),
}
}

View File

@@ -2,7 +2,6 @@ package serviceaccounttypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -13,7 +12,6 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableServiceAccount, error)
GetActiveByOrgIDAndName(context.Context, valuer.UUID, string) (*StorableServiceAccount, error)
GetByID(context.Context, valuer.UUID) (*StorableServiceAccount, error)
CountByOrgID(context.Context, valuer.UUID) (int64, error)
List(context.Context, valuer.UUID) ([]*StorableServiceAccount, error)
Update(context.Context, valuer.UUID, *StorableServiceAccount) error
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -27,12 +25,8 @@ type Store interface {
// Service Account Factor API Key
CreateFactorAPIKey(context.Context, *StorableFactorAPIKey) error
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*StorableFactorAPIKey, error)
GetFactorAPIKeyByName(context.Context, valuer.UUID, string) (*StorableFactorAPIKey, error)
GetFactorAPIKeyByKey(context.Context, string) (*StorableFactorAPIKey, error)
CountFactorAPIKeysByOrgID(context.Context, valuer.UUID) (int64, error)
ListFactorAPIKey(context.Context, valuer.UUID) ([]*StorableFactorAPIKey, error)
UpdateFactorAPIKey(context.Context, valuer.UUID, *StorableFactorAPIKey) error
UpdateLastObservedAt(context.Context, string, time.Time) error
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
RevokeAllFactorAPIKeys(context.Context, valuer.UUID) error

View File

@@ -281,6 +281,14 @@ type UserStore interface {
DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error
UpdatePassword(ctx context.Context, password *FactorPassword) error
// API KEY
CreateAPIKey(ctx context.Context, apiKey *StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id valuer.UUID, revokedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error)
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.subject}}</title>
</head>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#333;background:#fff">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#fff">
<tr>
<td align="center" style="padding:0">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;width:100%">
{{ if .format.Header.Enabled }}
<tr>
<td align="center" style="padding:16px 20px 16px">
<img src="{{.format.Header.LogoURL}}" alt="SigNoz" width="160" height="40" style="display:block;border:0;outline:none;max-width:100%;height:auto">
</td>
</tr>
{{ end }}
<tr>
<td style="padding:16px 20px 16px">
<p style="margin:0 0 16px;font-size:16px;color:#333">
Hi there,
</p>
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
An API key was {{.Event}} for your service account <strong>{{.Name}}</strong>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
<tr>
<td style="padding:20px;background:#f5f5f5;border-radius:6px;border-left:4px solid #4E74F8">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Key ID:</strong> {{.KeyID}}
</p>
</td>
</tr>
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Key Name:</strong> {{.KeyName}}
</p>
</td>
</tr>
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Created At:</strong> {{.KeyCreatedAt}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ if .format.Help.Enabled }}
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
Need help? Chat with our team in the SigNoz application or email us at <a href="mailto:{{.format.Help.Email}}" style="color:#4E74F8;text-decoration:none">{{.format.Help.Email}}</a>.
</p>
{{ end }}
<p style="margin:0;font-size:16px;color:#333;line-height:1.6">
Thanks,<br><strong>The SigNoz Team</strong>
</p>
</td>
</tr>
{{ if .format.Footer.Enabled }}
<tr>
<td align="center" style="padding:8px 16px 8px">
<p style="margin:0 0 8px;font-size:12px;color:#999;line-height:1.5">
<a href="https://signoz.io/terms-of-service/" style="color:#4E74F8;text-decoration:none">Terms of Service</a> - <a href="https://signoz.io/privacy/" style="color:#4E74F8;text-decoration:none">Privacy Policy</a>
</p>
<p style="margin:0;font-size:12px;color:#999;line-height:1.5">
&#169; 2026 SigNoz Inc.
</p>
</td>
</tr>
{{ end }}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
import requests
@@ -10,6 +11,95 @@ DEFAULT_TOLERANCE = 1e-9
QUERY_TIMEOUT = 30 # seconds
@dataclass
class TelemetryFieldKey:
name: str
field_data_type: str
field_context: str
def to_dict(self) -> Dict:
return {
"name": self.name,
"fieldDataType": self.field_data_type,
"fieldContext": self.field_context,
}
@dataclass
class OrderBy:
key: TelemetryFieldKey
direction: str = "asc"
def to_dict(self) -> Dict:
return {"key": self.key.to_dict(), "direction": self.direction}
@dataclass
class BuilderQuery:
signal: str
name: str = "A"
limit: Optional[int] = None
filter_expression: Optional[str] = None
select_fields: Optional[List[TelemetryFieldKey]] = None
order: Optional[List[OrderBy]] = None
def to_dict(self) -> Dict:
spec: Dict[str, Any] = {
"signal": self.signal,
"name": self.name,
}
if self.limit is not None:
spec["limit"] = self.limit
if self.filter_expression:
spec["filter"] = {"expression": self.filter_expression}
if self.select_fields:
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
if self.order:
spec["order"] = [o.to_dict() for o in self.order]
return {"type": "builder_query", "spec": spec}
@dataclass
class TraceOperatorQuery:
name: str
expression: str
return_spans_from: str
limit: Optional[int] = None
order: Optional[List[OrderBy]] = None
def to_dict(self) -> Dict:
spec: Dict[str, Any] = {
"name": self.name,
"expression": self.expression,
"returnSpansFrom": self.return_spans_from,
}
if self.limit is not None:
spec["limit"] = self.limit
if self.order:
spec["order"] = [o.to_dict() for o in self.order]
return {"type": "builder_trace_operator", "spec": spec}
@dataclass
class QueryRangeRequest:
start: int # nanoseconds
end: int # nanoseconds
queries: List[Union[BuilderQuery, TraceOperatorQuery]]
request_type: Optional[str] = "raw"
def to_dict(self) -> Dict:
body: Dict[str, Any] = {
"start": self.start,
"end": self.end,
"compositeQuery": {
"queries": [q.to_dict() for q in self.queries],
},
}
if self.request_type is not None:
body["requestType"] = self.request_type
return body
def make_query_request(
signoz: types.SigNoz,
token: str,

View File

@@ -1,55 +0,0 @@
"""Fixtures and helpers for service account tests."""
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
SA_BASE = "/api/v1/service_accounts"
def create_sa(
signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer"
) -> str:
"""Create a service account and return its ID."""
resp = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": name, "roles": [role]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
return resp.json()["data"]["id"]
def create_sa_with_key(
signoz: types.SigNoz, token: str, name: str, role: str = "signoz-admin"
) -> tuple:
"""Create a service account with an API key and return (sa_id, api_key)."""
sa_id = create_sa(signoz, token, name, role)
key_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "auth-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert key_resp.status_code == HTTPStatus.CREATED, key_resp.text
api_key = key_resp.json()["data"]["key"]
return sa_id, api_key
def find_sa_by_name(signoz: types.SigNoz, token: str, name: str) -> dict:
"""Find a service account by name from the list endpoint."""
list_resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert list_resp.status_code == HTTPStatus.OK, list_resp.text
return next(sa for sa in list_resp.json()["data"] if sa["name"] == name)

View File

@@ -159,25 +159,3 @@ def test_generate_connection_params(
assert (
data["signoz_api_url"] == "https://test-deployment.test.signoz.cloud"
), "signoz_api_url should be https://test-deployment.test.signoz.cloud"
# Verify the integration service account was created with viewer role, not admin.
# This guards against a privilege-escalation regression where the SA was
# previously created with admin access.
sa_list = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/service_accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert sa_list.status_code == HTTPStatus.OK
integration_sa = next(
(sa for sa in sa_list.json()["data"] if sa["name"] == "integration"),
None,
)
assert integration_sa is not None, "Integration service account should exist"
assert (
"signoz-viewer" in integration_sa["roles"]
), f"Integration SA should have VIEWER role, got {integration_sa['roles']}"
assert (
"signoz-admin" not in integration_sa["roles"]
), f"Integration SA must NOT have ADMIN role, got {integration_sa['roles']}"

View File

@@ -0,0 +1,117 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "admin",
"role": "ADMIN",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()
found_user = next(
(
user
for user in user_response["data"]
if user["email"] == "admin@integration.test"
),
None,
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert "data" in response.json()
found_pat = next(
(pat for pat in response.json()["data"] if pat["userId"] == found_user["id"]),
None,
)
assert found_pat is not None
assert found_pat["userId"] == found_user["id"]
assert found_pat["name"] == "admin"
assert found_pat["role"] == "ADMIN"
def test_api_key_role(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "viewer",
"role": "VIEWER",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "editor",
"role": "EDITOR",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN

View File

@@ -78,8 +78,27 @@ def test_change_role(
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
# Rotate token for new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={
"refreshToken": new_user_refresh_token,
},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
# Make some API call again which is protected
rotate_response = response.json()["data"]
new_user_token, new_user_refresh_token = (
rotate_response["accessToken"],
rotate_response["refreshToken"],
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,

View File

@@ -0,0 +1,670 @@
import csv
import io
import json
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import BuilderQuery, OrderBy, QueryRangeRequest, TelemetryFieldKey
def test_export_raw_data_get_not_allowed(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""
Tests:
1. GET request to export_raw_data is rejected with 405 Method Not Allowed
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data"),
timeout=10,
headers={
"authorization": f"Bearer {token}",
},
)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
def test_export_logs_csv(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert 3 logs with different severity levels and attributes.
Tests:
1. Export logs as CSV format
2. Verify CSV structure and content
3. Validate headers are present
4. Check log data is correctly formatted
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="Application started successfully",
severity_text="INFO",
resources={
"service.name": "api-service",
"deployment.environment": "production",
"host.name": "server-01",
},
attributes={
"http.method": "GET",
"http.status_code": 200,
"user.id": "user123",
},
),
Logs(
timestamp=now - timedelta(seconds=8),
body="Connection to database failed",
severity_text="ERROR",
resources={
"service.name": "api-service",
"deployment.environment": "production",
"host.name": "server-01",
},
attributes={
"error.type": "ConnectionError",
"db.name": "production_db",
},
),
Logs(
timestamp=now - timedelta(seconds=5),
body="Request processed",
severity_text="DEBUG",
resources={
"service.name": "worker-service",
"deployment.environment": "production",
"host.name": "server-02",
},
attributes={
"request.id": "req-456",
"duration_ms": 150.5,
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="logs", name="A")],
).to_dict()
# Export logs as CSV (default format)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data"),
json=body,
timeout=30,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "text/csv"
assert "attachment" in response.headers.get("Content-Disposition", "")
# Parse CSV content
csv_content = response.text
csv_reader = csv.DictReader(io.StringIO(csv_content))
rows = list(csv_reader)
assert len(rows) == 3, f"Expected 3 rows, got {len(rows)}"
# Verify log bodies are present in the exported data
bodies = [row.get("body") for row in rows]
assert "Application started successfully" in bodies
assert "Connection to database failed" in bodies
assert "Request processed" in bodies
# Verify severity levels
severities = [row.get("severity_text") for row in rows]
assert "INFO" in severities
assert "ERROR" in severities
assert "DEBUG" in severities
def test_export_logs_jsonl(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert 2 logs with different attributes.
Tests:
1. Export logs as JSONL format
2. Verify JSONL structure and content
3. Check each line is valid JSON
4. Validate log data is correctly formatted
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="User logged in",
severity_text="INFO",
resources={
"service.name": "auth-service",
"deployment.environment": "staging",
},
attributes={
"user.email": "test@example.com",
"session.id": "sess-789",
},
),
Logs(
timestamp=now - timedelta(seconds=5),
body="Payment processed successfully",
severity_text="INFO",
resources={
"service.name": "payment-service",
"deployment.environment": "staging",
},
attributes={
"transaction.id": "txn-123",
"amount": 99.99,
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="logs", name="A")],
).to_dict()
# Export logs as JSONL
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
assert "attachment" in response.headers.get("Content-Disposition", "")
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 2, f"Expected 2 lines, got {len(jsonl_lines)}"
# Verify each line is valid JSON
json_objects = []
for line in jsonl_lines:
obj = json.loads(line)
json_objects.append(obj)
assert "id" in obj
assert "timestamp" in obj
assert "body" in obj
assert "severity_text" in obj
# Verify log bodies
bodies = [obj.get("body") for obj in json_objects]
assert "User logged in" in bodies
assert "Payment processed successfully" in bodies
def test_export_logs_with_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert logs with different severity levels.
Tests:
1. Export logs with filter applied
2. Verify only filtered logs are returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="Info message",
severity_text="INFO",
resources={
"service.name": "test-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=8),
body="Error message",
severity_text="ERROR",
resources={
"service.name": "test-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=5),
body="Another error message",
severity_text="ERROR",
resources={
"service.name": "test-service",
},
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="logs", name="A", filter_expression="severity_text = 'ERROR'"
)
],
).to_dict()
# Export logs with filter
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 2, f"Expected 2 lines (filtered), got {len(jsonl_lines)}"
# Verify only ERROR logs are returned
for line in jsonl_lines:
obj = json.loads(line)
assert obj["severity_text"] == "ERROR"
assert "error message" in obj["body"].lower()
def test_export_logs_with_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert 5 logs.
Tests:
1. Export logs with limit applied
2. Verify only limited number of logs are returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
logs = []
for i in range(5):
logs.append(
Logs(
timestamp=now - timedelta(seconds=i),
body=f"Log message {i}",
severity_text="INFO",
resources={
"service.name": "test-service",
},
attributes={
"index": i,
},
)
)
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="logs", name="A", limit=3)],
).to_dict()
# Export logs with limit
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=csv"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "text/csv"
# Parse CSV content
csv_content = response.text
csv_reader = csv.DictReader(io.StringIO(csv_content))
rows = list(csv_reader)
assert len(rows) == 3, f"Expected 3 rows (limited), got {len(rows)}"
def test_export_logs_with_columns(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert logs with various attributes.
Tests:
1. Export logs with specific columns
2. Verify only specified columns are returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="Test log message",
severity_text="INFO",
resources={
"service.name": "test-service",
"deployment.environment": "production",
},
attributes={
"http.method": "GET",
"http.status_code": 200,
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("timestamp", "string", "log"),
TelemetryFieldKey("severity_text", "string", "log"),
TelemetryFieldKey("body", "string", "log"),
],
)
],
).to_dict()
# Export logs with specific columns
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=csv"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "text/csv"
# Parse CSV content
csv_content = response.text
csv_reader = csv.DictReader(io.StringIO(csv_content))
rows = list(csv_reader)
assert len(rows) == 1
# Verify the specified columns are present
row = rows[0]
assert "timestamp" in row
assert "severity_text" in row
assert "body" in row
assert row["severity_text"] == "INFO"
assert row["body"] == "Test log message"
def test_export_logs_with_order_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert logs at different timestamps.
Tests:
1. Export logs with ascending timestamp order
2. Verify logs are returned in correct order
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="First log",
severity_text="INFO",
resources={
"service.name": "test-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=5),
body="Second log",
severity_text="INFO",
resources={
"service.name": "test-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=1),
body="Third log",
severity_text="INFO",
resources={
"service.name": "test-service",
},
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="logs",
name="A",
order=[OrderBy(TelemetryFieldKey("timestamp", "string", "log"), "asc")],
)
],
).to_dict()
# Export logs with ascending order
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 3
# Verify order - first log should be "First log" (oldest)
json_objects = [json.loads(line) for line in jsonl_lines]
assert json_objects[0]["body"] == "First log"
assert json_objects[1]["body"] == "Second log"
assert json_objects[2]["body"] == "Third log"
def test_export_logs_with_complex_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert logs with various service names and severity levels.
Tests:
1. Export logs with complex filter (multiple conditions)
2. Verify only logs matching all conditions are returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=10),
body="API error occurred",
severity_text="ERROR",
resources={
"service.name": "api-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=8),
body="Worker info message",
severity_text="INFO",
resources={
"service.name": "worker-service",
},
attributes={},
),
Logs(
timestamp=now - timedelta(seconds=5),
body="API info message",
severity_text="INFO",
resources={
"service.name": "api-service",
},
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="logs",
name="A",
filter_expression="service.name = 'api-service' AND severity_text = 'ERROR'",
)
],
).to_dict()
# Export logs with complex filter
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert (
len(jsonl_lines) == 1
), f"Expected 1 line (complex filter), got {len(jsonl_lines)}"
# Verify the filtered log
filtered_obj = json.loads(jsonl_lines[0])
assert filtered_obj["body"] == "API error occurred"
assert filtered_obj["severity_text"] == "ERROR"

View File

@@ -0,0 +1,763 @@
import csv
import io
import json
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
BuilderQuery,
OrderBy,
QueryRangeRequest,
TelemetryFieldKey,
TraceOperatorQuery,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_export_raw_data_get_not_allowed(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""
Tests:
1. GET request to export_raw_data is rejected with 405 Method Not Allowed
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data"),
timeout=10,
headers={
"authorization": f"Bearer {token}",
},
)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
def test_export_traces_csv(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert 3 traces with different attributes.
Tests:
1. Export traces as CSV format
2. Verify CSV structure and content
3. Validate headers are present
4. Check trace data is correctly formatted
"""
http_service_trace_id = TraceIdGenerator.trace_id()
http_service_span_id = TraceIdGenerator.span_id()
http_service_db_span_id = TraceIdGenerator.span_id()
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=http_service_trace_id,
span_id=http_service_span_id,
parent_span_id="",
name="POST /integration",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
},
attributes={
"net.transport": "IP.TCP",
"http.scheme": "http",
"http.user_agent": "Integration Test",
"http.request.method": "POST",
"http.response.status_code": "200",
},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
duration=timedelta(seconds=0.5),
trace_id=http_service_trace_id,
span_id=http_service_db_span_id,
parent_span_id=http_service_span_id,
name="SELECT",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=2),
trace_id=topic_service_trace_id,
span_id=topic_service_span_id,
parent_span_id="",
name="topic publish",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "topic-service",
"os.type": "linux",
"host.name": "linux-001",
},
attributes={
"message.type": "SENT",
"messaging.operation": "publish",
"messaging.message.id": "001",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="traces", name="A", limit=1000)],
).to_dict()
# Export traces as CSV
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data"),
json=body,
timeout=30,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "text/csv"
assert "attachment" in response.headers.get("Content-Disposition", "")
# Parse CSV content
csv_content = response.text
csv_reader = csv.DictReader(io.StringIO(csv_content))
rows = list(csv_reader)
assert len(rows) == 3, f"Expected 3 rows, got {len(rows)}"
# Verify trace IDs are present in the exported data
trace_ids = [row.get("trace_id") for row in rows]
assert http_service_trace_id in trace_ids
assert topic_service_trace_id in trace_ids
# Verify span names are present
span_names = [row.get("name") for row in rows]
assert "POST /integration" in span_names
assert "SELECT" in span_names
assert "topic publish" in span_names
def test_export_traces_jsonl(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert 2 traces with different attributes.
Tests:
1. Export traces as JSONL format
2. Verify JSONL structure and content
3. Check each line is valid JSON
4. Validate trace data is correctly formatted
"""
http_service_trace_id = TraceIdGenerator.trace_id()
http_service_span_id = TraceIdGenerator.span_id()
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=http_service_trace_id,
span_id=http_service_span_id,
parent_span_id="",
name="POST /api/test",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "api-service",
"deployment.environment": "staging",
},
attributes={
"http.request.method": "POST",
"http.response.status_code": "201",
},
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=1),
trace_id=topic_service_trace_id,
span_id=topic_service_span_id,
parent_span_id="",
name="queue.process",
kind=TracesKind.SPAN_KIND_CONSUMER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "queue-service",
"deployment.environment": "staging",
},
attributes={
"messaging.operation": "process",
"messaging.system": "rabbitmq",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="traces", name="A", limit=1000)],
).to_dict()
# Export traces as JSONL
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
assert "attachment" in response.headers.get("Content-Disposition", "")
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 2, f"Expected 2 lines, got {len(jsonl_lines)}"
# Verify each line is valid JSON
json_objects = []
for line in jsonl_lines:
obj = json.loads(line)
json_objects.append(obj)
assert "trace_id" in obj
assert "span_id" in obj
assert "name" in obj
# Verify trace IDs are present
trace_ids = [obj.get("trace_id") for obj in json_objects]
assert http_service_trace_id in trace_ids
assert topic_service_trace_id in trace_ids
# Verify span names are present
span_names = [obj.get("name") for obj in json_objects]
assert "POST /api/test" in span_names
assert "queue.process" in span_names
def test_export_traces_with_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert traces with different service names.
Tests:
1. Export traces with filter applied
2. Verify only filtered traces are returned
"""
service_a_trace_id = TraceIdGenerator.trace_id()
service_a_span_id = TraceIdGenerator.span_id()
service_b_trace_id = TraceIdGenerator.trace_id()
service_b_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=service_a_trace_id,
span_id=service_a_span_id,
parent_span_id="",
name="operation-a",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "service-a",
},
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=1),
trace_id=service_b_trace_id,
span_id=service_b_span_id,
parent_span_id="",
name="operation-b",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "service-b",
},
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="traces",
name="A",
limit=1000,
filter_expression="service.name = 'service-a'",
)
],
).to_dict()
# Export traces with filter
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 1, f"Expected 1 line (filtered), got {len(jsonl_lines)}"
# Verify the filtered trace
filtered_obj = json.loads(jsonl_lines[0])
assert filtered_obj["trace_id"] == service_a_trace_id
assert filtered_obj["name"] == "operation-a"
def test_export_traces_with_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert 5 traces.
Tests:
1. Export traces with limit applied
2. Verify only limited number of traces are returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
traces = []
for i in range(5):
traces.append(
Traces(
timestamp=now - timedelta(seconds=i),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name=f"operation-{i}",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "test-service",
},
attributes={},
)
)
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[BuilderQuery(signal="traces", name="A", limit=3)],
).to_dict()
# Export traces with limit
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=csv"),
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "text/csv"
# Parse CSV content
csv_content = response.text
csv_reader = csv.DictReader(io.StringIO(csv_content))
rows = list(csv_reader)
assert len(rows) == 3, f"Expected 3 rows (limited), got {len(rows)}"
def test_export_traces_multiple_queries_rejected(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""
Tests:
1. POST with multiple builder queries but no trace operator is rejected
2. Verify 400 error is returned
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
request_type=None,
queries=[
BuilderQuery(
signal="traces",
name="A",
limit=1000,
filter_expression="service.name = 'service-a'",
),
BuilderQuery(
signal="traces",
name="B",
limit=1000,
filter_expression="service.name = 'service-b'",
),
],
).to_dict()
url = signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl")
response = requests.post(
url,
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_export_traces_with_composite_query_trace_operator(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert multiple traces with parent-child relationships.
Tests:
1. Export traces using trace operator in composite query (POST)
2. Verify trace operator query works correctly
"""
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id_1 = TraceIdGenerator.span_id()
child_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="parent-operation",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "parent-service",
},
attributes={
"operation.type": "parent",
},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=2),
trace_id=parent_trace_id,
span_id=child_span_id_1,
parent_span_id=parent_span_id,
name="child-operation-1",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "parent-service",
},
attributes={
"operation.type": "child",
},
),
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=1),
trace_id=parent_trace_id,
span_id=child_span_id_2,
parent_span_id=parent_span_id,
name="child-operation-2",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "parent-service",
},
attributes={
"operation.type": "child",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
# A: spans with operation.type = 'parent'
query_a = BuilderQuery(
signal="traces",
name="A",
limit=1000,
filter_expression="operation.type = 'parent'",
)
# B: spans with operation.type = 'child'
query_b = BuilderQuery(
signal="traces",
name="B",
limit=1000,
filter_expression="operation.type = 'child'",
)
# Trace operator: find traces where A has a direct descendant B
query_c = TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=1000,
order=[OrderBy(TelemetryFieldKey("timestamp", "string", "span"), "desc")],
)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[query_a, query_b, query_c],
).to_dict()
url = signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl")
response = requests.post(
url,
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 1, f"Expected at least 1 line, got {len(jsonl_lines)}"
# Verify all returned spans belong to the matched trace
json_objects = [json.loads(line) for line in jsonl_lines]
trace_ids = [obj.get("trace_id") for obj in json_objects]
assert all(tid == parent_trace_id for tid in trace_ids)
# Verify the parent span (returnSpansFrom = "A") is present
span_names = [obj.get("name") for obj in json_objects]
assert "parent-operation" in span_names
def test_export_traces_with_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[List[Traces]], None],
) -> None:
"""
Setup:
Insert traces with various attributes.
Tests:
1. Export traces with specific select fields via POST
2. Verify only specified fields are returned in the output
"""
trace_id = TraceIdGenerator.trace_id()
span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=2),
trace_id=trace_id,
span_id=span_id,
parent_span_id="",
name="test-operation",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"service.name": "test-service",
"deployment.environment": "production",
"host.name": "server-01",
},
attributes={
"http.method": "POST",
"http.status_code": "201",
"user.id": "user123",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Calculate timestamps in nanoseconds
start_ns = int((now - timedelta(minutes=5)).timestamp() * 1e9)
end_ns = int(now.timestamp() * 1e9)
body = QueryRangeRequest(
start=start_ns,
end=end_ns,
queries=[
BuilderQuery(
signal="traces",
name="A",
limit=1000,
select_fields=[
TelemetryFieldKey("trace_id", "string", "span"),
TelemetryFieldKey("span_id", "string", "span"),
TelemetryFieldKey("name", "string", "span"),
TelemetryFieldKey("service.name", "string", "resource"),
],
)
],
).to_dict()
url = signoz.self.host_configs["8080"].get("/api/v1/export_raw_data?format=jsonl")
response = requests.post(
url,
json=body,
timeout=10,
headers={
"authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/x-ndjson"
# Parse JSONL content
jsonl_lines = response.text.strip().split("\n")
assert len(jsonl_lines) == 1
# Verify the selected fields are present
result = json.loads(jsonl_lines[0])
assert "trace_id" in result
assert "span_id" in result
assert "name" in result
# Verify values
assert result["trace_id"] == trace_id
assert result["span_id"] == span_id
assert result["name"] == "test-operation"

View File

@@ -1,242 +0,0 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa, find_sa_by_name
logger = setup_logger(__name__)
def test_create_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "test-sa", "roles": ["signoz-admin"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
data = response.json()["data"]
assert "id" in data
assert len(data["id"]) > 0
def test_create_service_account_invalid_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# name with spaces should be rejected
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "invalid name", "roles": ["signoz-admin"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_create_service_account_empty_roles(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "no-roles-sa", "roles": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_list_service_accounts(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert isinstance(data, list)
# should contain the SA we created in the earlier test
names = [sa["name"] for sa in data]
assert "test-sa" in names
def test_get_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "test-sa")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["id"] == sa["id"]
assert data["name"] == "test-sa"
assert data["status"] == "active"
assert "email" in data
assert "roles" in data
def test_get_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"{SA_BASE}/00000000-0000-0000-0000-000000000000"
),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
def test_update_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "test-sa")
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
json={"name": "test-sa-updated", "roles": ["signoz-viewer"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify the update
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.json()["data"]["name"] == "test-sa-updated"
def test_disable_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-to-disable")
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/status"),
json={"status": "disabled"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify status changed
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.json()["data"]["status"] == "disabled"
def test_create_after_disable_reuses_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The partial unique index on (name, org_id) excludes disabled rows,
so create → disable → create with the same name must succeed."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_name = "sa-reuse-name"
# 1. create
first_id = create_sa(signoz, token, sa_name)
# 2. creating again with the same name should fail (conflict)
dup_resp = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": sa_name, "roles": ["signoz-viewer"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert dup_resp.status_code == HTTPStatus.CONFLICT, dup_resp.text
# 3. disable the first one
disable_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{first_id}/status"),
json={"status": "disabled"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert disable_resp.status_code == HTTPStatus.NO_CONTENT
# 4. now creating with the same name should succeed
second_id = create_sa(signoz, token, sa_name)
assert second_id != first_id, "New SA should have a different ID"
def test_delete_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-to-delete")
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify it's gone
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.status_code == HTTPStatus.NOT_FOUND

View File

@@ -1,268 +0,0 @@
import time
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa, find_sa_by_name
logger = setup_logger(__name__)
def test_create_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-for-keys")
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "my-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
data = response.json()["data"]
assert "id" in data
assert "key" in data
assert len(data["key"]) > 0
def test_create_api_key_duplicate_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
# creating a key with the same name should fail
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
json={"name": "my-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
def test_list_api_keys(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert isinstance(data, list)
assert len(data) >= 1
key_entry = data[0]
assert "id" in key_entry
assert "name" in key_entry
assert key_entry["name"] == "my-key"
def test_update_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_id = keys_resp.json()["data"][0]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys/{key_id}"),
json={"name": "renamed-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_revoke_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-revoke-key")
create_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "key-to-revoke", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_resp.status_code == HTTPStatus.CREATED
key_id = create_resp.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_create_api_key_with_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Key created with a future expiresAt should be usable."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-expiry")
future_ts = int(time.time()) + 3600 # 1 hour from now
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "future-key", "expiresAt": future_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
api_key = response.json()["data"]["key"]
# key should work since it hasn't expired
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert dash_resp.status_code == HTTPStatus.OK, dash_resp.text
# verify expiresAt is stored correctly
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_entry = next(k for k in keys_resp.json()["data"] if k["name"] == "future-key")
assert key_entry["expiresAt"] == future_ts
def test_create_api_key_with_past_expiry_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Creating a key with an already-past expiresAt should be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-past-expiry")
past_ts = int(time.time()) - 60 # 1 minute ago
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "expired-key", "expiresAt": past_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for past expiresAt, got {response.status_code}: {response.text}"
def test_create_api_key_no_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Key with expiresAt=0 should never expire."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-no-expiry")
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "forever-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
api_key = response.json()["data"]["key"]
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert dash_resp.status_code == HTTPStatus.OK, dash_resp.text
# verify expiresAt is 0
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_entry = next(k for k in keys_resp.json()["data"] if k["name"] == "forever-key")
assert key_entry["expiresAt"] == 0
def test_update_api_key_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Updating expiresAt to a past value should be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-update-expiry")
# create with no expiry
create_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "update-expiry-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_resp.status_code == HTTPStatus.CREATED
key_id = create_resp.json()["data"]["id"]
api_key = create_resp.json()["data"]["key"]
# updating to expire in the past should be rejected
past_ts = int(time.time()) - 60
update_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
json={"name": "update-expiry-key", "expiresAt": past_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert (
update_resp.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for past expiresAt update, got {update_resp.status_code}: {update_resp.text}"
# key should still work since the update was rejected
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
dash_resp.status_code == HTTPStatus.OK
), f"Key should still work after rejected update, got {dash_resp.status_code}: {dash_resp.text}"

View File

@@ -1,317 +0,0 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa_with_key
logger = setup_logger(__name__)
def test_sa_key_auth_on_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account API key with admin role can access dashboards."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-dashboard-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
def test_sa_key_forbidden_on_user_me(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account key must not access /api/v1/user/me — it's user-only."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-user-me-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
response.status_code == HTTPStatus.FORBIDDEN
), f"Expected 403 for SA on /user/me, got {response.status_code}: {response.text}"
def test_sa_key_forbidden_on_user_preferences(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account key must not access user preference endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-pref-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/preferences"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
response.status_code == HTTPStatus.FORBIDDEN
), f"Expected 403 for SA on /user/preferences, got {response.status_code}: {response.text}"
def test_sa_role_access_admin(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Admin SA can access admin, edit, and view endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-role-admin", role="signoz-admin")
# AdminAccess: list service accounts
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Admin SA should access admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: create a dashboard
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "admin-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Admin SA should access edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Admin SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_role_access_editor(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Editor SA can access edit and view endpoints but not admin."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(
signoz, token, "sa-role-editor", role="signoz-editor"
)
# AdminAccess: should be forbidden
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Editor SA should be forbidden from admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: create a dashboard
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "editor-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Editor SA should access edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Editor SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_role_access_viewer(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Viewer SA can access view endpoints but not edit or admin."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(
signoz, token, "sa-role-viewer", role="signoz-viewer"
)
# AdminAccess: should be forbidden
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Viewer SA should be forbidden from admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: should be forbidden
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "viewer-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Viewer SA should be forbidden from edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Viewer SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_key_disabled_account_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A disabled service account's key must be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id, api_key = create_sa_with_key(signoz, token, "sa-disable-auth")
# verify the key works before disabling
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
# disable the SA
disable_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/status"),
json={"status": "disabled"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert disable_resp.status_code == HTTPStatus.NO_CONTENT
# now the key should be rejected
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code in (
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
), f"Expected 401/403 for disabled SA, got {response.status_code}: {response.text}"
def test_sa_key_revoked_key_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A revoked API key must be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id, api_key = create_sa_with_key(signoz, token, "sa-revoke-auth")
# verify the key works first
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
# find the key id
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_id = keys_resp.json()["data"][0]["id"]
# revoke it
revoke_resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert revoke_resp.status_code == HTTPStatus.NO_CONTENT
# now the key should be rejected
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
response.status_code == HTTPStatus.UNAUTHORIZED
), f"Expected 401 for revoked key, got {response.status_code}: {response.text}"
def test_user_token_still_works_on_user_me(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Verify that normal user JWT tokens still work on user-only endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["email"] == USER_ADMIN_EMAIL
def test_user_token_still_works_on_user_preferences(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Verify that normal user JWT tokens still work on preference endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/preferences"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"] is not None