mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-09 21:10:41 +01:00
Compare commits
7 Commits
v0.141.0
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9b4711e4 | ||
|
|
13c92c0010 | ||
|
|
2f19c4811c | ||
|
|
1168aff204 | ||
|
|
99dcd79979 | ||
|
|
bff855a05f | ||
|
|
337e62c775 |
@@ -20308,9 +20308,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:read
|
||||
summary: Get users by role id
|
||||
tags:
|
||||
- users
|
||||
@@ -24872,9 +24872,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:attach
|
||||
- role:attach
|
||||
summary: Create user role
|
||||
tags:
|
||||
- users
|
||||
@@ -24924,9 +24926,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:detach
|
||||
- role:detach
|
||||
summary: Delete user role
|
||||
tags:
|
||||
- users
|
||||
@@ -24987,9 +24991,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:read
|
||||
summary: Get user role
|
||||
tags:
|
||||
- users
|
||||
@@ -25035,9 +25039,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:list
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
@@ -25097,9 +25101,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:create
|
||||
- user:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:create
|
||||
- user:attach
|
||||
- role:attach
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
@@ -25143,9 +25151,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:delete
|
||||
summary: Delete user
|
||||
tags:
|
||||
- users
|
||||
@@ -25200,9 +25208,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:read
|
||||
summary: Get user by user id
|
||||
tags:
|
||||
- users
|
||||
@@ -25256,9 +25264,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:update
|
||||
summary: Update user v2
|
||||
tags:
|
||||
- users
|
||||
@@ -25314,9 +25322,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- factor-password:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- factor-password:list
|
||||
summary: Get reset password token for a user
|
||||
tags:
|
||||
- users
|
||||
@@ -25379,9 +25387,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- factor-password:create
|
||||
- user:attach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- factor-password:create
|
||||
- user:attach
|
||||
summary: Create or regenerate reset password token for a user
|
||||
tags:
|
||||
- users
|
||||
@@ -25439,9 +25449,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- user:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- user:read
|
||||
summary: Get user roles
|
||||
tags:
|
||||
- users
|
||||
|
||||
@@ -121,7 +121,7 @@ The pieces:
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). If the target list is optional in the payload (e.g. `userRoles` at user creation), set `OptionalTargets`: when no target ids resolve, the attach is vacuous and the def is skipped entirely — without it, the empty-id contract would check collection-level access on the target. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
openInNewTab,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
openInNewTab: boolean;
|
||||
}): any {
|
||||
// Display block to make the whole cell clickable
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{ display: 'block' }}
|
||||
target={openInNewTab ? '_blank' : '_self'}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const transformDataWithDate = (
|
||||
data: QueryDataV3[],
|
||||
): Omit<ILog, 'timestamp'>[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
title: 'Timestamp',
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
value / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'httpMethod' ||
|
||||
name === 'responseStatusCode' ||
|
||||
name === 'response_status_code' ||
|
||||
name === 'http_method'
|
||||
) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'durationNano' || name === 'duration_nano') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>{getMs(value)}ms</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>
|
||||
<LineClampedText text={value} lines={3} />
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
responsive: ['md'],
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
const row = item.data as Record<string, unknown>;
|
||||
return {
|
||||
...row,
|
||||
timestamp: item.timestamp,
|
||||
id: row.span_id,
|
||||
};
|
||||
}) as TracesTableRow[];
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
.loading-traces {
|
||||
padding: 24px 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-traces-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-gif {
|
||||
height: 72px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import './TraceLoading.styles.scss';
|
||||
|
||||
export function TracesLoading(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<div className="loading-traces">
|
||||
<div className="loading-traces-content">
|
||||
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
|
||||
|
||||
<Typography>
|
||||
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
DURATION_FIELD_NAMES,
|
||||
STATUS_FIELD_NAMES,
|
||||
TIMESTAMP_FIELD_NAMES,
|
||||
TRACE_ID_FIELD_NAMES,
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
const text = String(formatted);
|
||||
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (STATUS_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name} title={text}>
|
||||
{text}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldCell;
|
||||
@@ -0,0 +1,26 @@
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 54px;
|
||||
--tanstack-table-header-height: 54px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-header-padding-left-override: 5px;
|
||||
|
||||
--tanstack-cell-header-padding-left-first-column: 15px;
|
||||
|
||||
--tanstack-plain-body-line-clamp: 1;
|
||||
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
data: TracesTableRow[];
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
columnStorageKey?: string;
|
||||
respectColumnOrder?: boolean;
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
},
|
||||
[history, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
columns={columns}
|
||||
className={styles.tracesTable}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
isLoading={isFetching}
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
|
||||
export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal file
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* AI Assistant page-action factories for the Traces Explorer.
|
||||
*
|
||||
* Mirrors the logs equivalents — each factory closes over live page
|
||||
* state/callbacks so `execute()` always operates on the current query, and
|
||||
* the page component instantiates them via `useMemo` + `usePageActions`.
|
||||
*
|
||||
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
|
||||
* BOTH `filters.items` and `filter.expression` and then re-using the same
|
||||
* URL parser shape via `redirectWithQueryBuilderData`.
|
||||
*/
|
||||
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
aiFilterToTagFilterItem,
|
||||
FILTER_OP_ENUM,
|
||||
FILTER_VALUE_DESCRIPTION,
|
||||
FilterDeps,
|
||||
replaceFirstQueryData,
|
||||
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
|
||||
import {
|
||||
ActionResult,
|
||||
PageAction,
|
||||
} from 'container/AIAssistant/pageActions/types';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface AIFilter {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RunQueryParams {
|
||||
filters: AIFilter[];
|
||||
}
|
||||
|
||||
interface AddFilterParams {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
|
||||
|
||||
interface ChangeViewParams {
|
||||
view: TracesView;
|
||||
}
|
||||
|
||||
interface SaveViewParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all active span filters and navigate to the updated query URL
|
||||
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
|
||||
*/
|
||||
export function tracesRunQueryAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<RunQueryParams> {
|
||||
return {
|
||||
id: 'traces.runQuery',
|
||||
description: 'Replace the active trace filters and re-run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: [...FILTER_OP_ENUM],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: FILTER_VALUE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['filters'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ filters }): Promise<ActionResult> => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) {
|
||||
throw new Error('No active query found in Traces Explorer.');
|
||||
}
|
||||
|
||||
const tagItems = filters.map(aiFilterToTagFilterItem);
|
||||
const newFilters = { items: tagItems, op: 'AND' };
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: newFilters,
|
||||
filter: convertFiltersToExpression(newFilters),
|
||||
};
|
||||
|
||||
deps.handleSetQueryData(0, updatedBuilderQuery);
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
|
||||
};
|
||||
},
|
||||
getContext: (): Record<string, unknown> => ({
|
||||
filters:
|
||||
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
|
||||
(f: TagFilterItem) => ({
|
||||
key: f.key?.key,
|
||||
op: f.op,
|
||||
value: f.value,
|
||||
}),
|
||||
) ?? [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single filter to the existing trace query and navigate to the
|
||||
* updated URL.
|
||||
*/
|
||||
export function tracesAddFilterAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<AddFilterParams> {
|
||||
return {
|
||||
id: 'traces.addFilter',
|
||||
description: 'Add a single filter to the current trace query and re-run',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: [...FILTER_OP_ENUM],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: FILTER_VALUE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ key, op, value }): Promise<ActionResult> => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) {
|
||||
throw new Error('No active query found in Traces Explorer.');
|
||||
}
|
||||
|
||||
const existing = baseQuery.filters?.items ?? [];
|
||||
const newItem = aiFilterToTagFilterItem({ key, op, value });
|
||||
const newFilters = { items: [...existing, newItem], op: 'AND' };
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: newFilters,
|
||||
filter: convertFiltersToExpression(newFilters),
|
||||
};
|
||||
|
||||
deps.handleSetQueryData(0, updatedBuilderQuery);
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the traces explorer between list / timeseries / table / trace views.
|
||||
*/
|
||||
export function tracesChangeViewAction(deps: {
|
||||
onChangeView: (view: TracesView) => void;
|
||||
}): PageAction<ChangeViewParams> {
|
||||
return {
|
||||
id: 'traces.changeView',
|
||||
description:
|
||||
'Switch the Traces Explorer between list, timeseries, table, and trace views',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: {
|
||||
type: 'string',
|
||||
enum: ['list', 'timeseries', 'table', 'trace'],
|
||||
description: 'The panel view to switch to',
|
||||
},
|
||||
},
|
||||
required: ['view'],
|
||||
},
|
||||
execute: async ({ view }): Promise<ActionResult> => {
|
||||
deps.onChangeView(view);
|
||||
return { summary: `Switched to the "${view}" view.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current trace query as a named view (stub — wires to real API
|
||||
* when available).
|
||||
*/
|
||||
export function tracesSaveViewAction(deps: {
|
||||
onSaveView: (name: string) => Promise<void>;
|
||||
}): PageAction<SaveViewParams> {
|
||||
return {
|
||||
id: 'traces.saveView',
|
||||
description: 'Save the current trace query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name for the saved view' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }): Promise<ActionResult> => {
|
||||
await deps.onSaveView(name);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Atom,
|
||||
Filter,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import './ToolbarActions.styles.scss';
|
||||
|
||||
interface LeftToolbarActionsProps {
|
||||
items: any;
|
||||
selectedView: string;
|
||||
onChangeSelectedView: (view: ExplorerViews) => void;
|
||||
showFilter: boolean;
|
||||
handleFilterVisibilityChange: () => void;
|
||||
}
|
||||
|
||||
const activeTab = 'active-tab';
|
||||
|
||||
export default function LeftToolbarActions({
|
||||
items,
|
||||
selectedView,
|
||||
onChangeSelectedView,
|
||||
showFilter,
|
||||
handleFilterVisibilityChange,
|
||||
}: LeftToolbarActionsProps): JSX.Element {
|
||||
const { clickhouse, list, timeseries, table, trace } = items;
|
||||
|
||||
return (
|
||||
<div className="left-toolbar">
|
||||
{!showFilter && (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size={12} />
|
||||
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="left-toolbar-query-actions">
|
||||
{list?.show && (
|
||||
<Tooltip title="List View">
|
||||
<Button
|
||||
disabled={list.disabled}
|
||||
className={cx(
|
||||
'list-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === list.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(list.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="search-view" />
|
||||
List View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{trace?.show && (
|
||||
<Tooltip title="Trace View">
|
||||
<Button
|
||||
disabled={trace.disabled}
|
||||
className={cx(
|
||||
'trace-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === trace.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(trace.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="trace-view" />
|
||||
Trace View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{timeseries?.show && (
|
||||
<Tooltip title="Time Series">
|
||||
<Button
|
||||
disabled={timeseries.disabled}
|
||||
className={cx(
|
||||
'timeseries-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === timeseries.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(timeseries.key)}
|
||||
>
|
||||
<Atom size={14} data-testid="query-builder-view" />
|
||||
Time Series
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{clickhouse?.show && (
|
||||
<Tooltip title="Clickhouse">
|
||||
<Button
|
||||
disabled={clickhouse.disabled}
|
||||
className={cx(
|
||||
'clickhouse-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === clickhouse.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(clickhouse.key)}
|
||||
>
|
||||
<Terminal size={14} data-testid="clickhouse-view" />
|
||||
Clickhouse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{table?.show && (
|
||||
<Tooltip title="Table">
|
||||
<Button
|
||||
disabled={table.disabled}
|
||||
className={cx(
|
||||
'table-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === table.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(table.key)}
|
||||
>
|
||||
<Binoculars size={14} data-testid="query-builder-view-v2" />
|
||||
Table
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.left-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.left-toolbar-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.prom-ql-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--primary-background);
|
||||
border-bottom: 1px solid var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--l3-background);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.frequency-chart-view-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-robin-600);
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.loading-btn {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 33px;
|
||||
padding: 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-run {
|
||||
display: flex;
|
||||
height: 33px;
|
||||
padding: 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 0 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
border: none;
|
||||
}
|
||||
.cancel-run:hover {
|
||||
background-color: var(--bg-cherry-400) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ type provider struct {
|
||||
authzService authz.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
userGetter user.Getter
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
authDomainModule authdomain.Module
|
||||
@@ -97,6 +98,7 @@ func NewFactory(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -144,6 +146,7 @@ func NewFactory(
|
||||
authzService,
|
||||
orgHandler,
|
||||
userHandler,
|
||||
userGetter,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
authDomainModule,
|
||||
@@ -193,6 +196,7 @@ func newProvider(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -240,6 +244,7 @@ func newProvider(
|
||||
router: router,
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
userGetter: userGetter,
|
||||
authzService: authzService,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
|
||||
@@ -6,24 +6,35 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
|
||||
ID: "ListUsers",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users v2",
|
||||
Description: "This endpoint lists all users for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListUsers",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users v2",
|
||||
Description: "This endpoint lists all users for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,20 +72,43 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
|
||||
TargetSelector: provider.roleSelector,
|
||||
OptionalTargets: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -95,88 +129,148 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
|
||||
ID: "GetUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user by user id",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user by user id",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
|
||||
ID: "UpdateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user v2",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user v2",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
|
||||
ID: "DeleteUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
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 {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token for a user",
|
||||
Description: "This endpoint returns the existing reset password token for a user.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token for a user",
|
||||
Description: "This endpoint returns the existing reset password token for a user.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "CreateResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create or regenerate reset password token for a user",
|
||||
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create or regenerate reset password token for a user",
|
||||
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ParentResource: coretypes.ResourceUser,
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
ChildIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -248,90 +342,196 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user roles",
|
||||
Description: "This endpoint returns the user roles by user id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user roles",
|
||||
Description: "This endpoint returns the user roles by user id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
|
||||
ID: "GetUsersByRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get users by role id",
|
||||
Description: "This endpoint returns the users having the role by role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUsersByRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get users by role id",
|
||||
Description: "This endpoint returns the users having the role by role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
|
||||
ID: "CreateUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user role",
|
||||
Description: "This endpoint assigns a role to a user",
|
||||
Request: new(authtypes.PostableUserRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user role",
|
||||
Description: "This endpoint assigns a role to a user",
|
||||
Request: new(authtypes.PostableUserRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
|
||||
ID: "GetUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user role",
|
||||
Description: "This endpoint gets an existing user role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user role",
|
||||
Description: "This endpoint gets an existing user role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: provider.userRoleUserIDExtractor(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
|
||||
ID: "DeleteUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user role",
|
||||
Description: "This endpoint revokes a role from a user",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user role",
|
||||
Description: "This endpoint revokes a role from a user",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userRole.UserID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userRole.RoleID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,9 +53,20 @@ type AttachDetachSiblingResourceDef struct {
|
||||
TargetResource coretypes.Resource
|
||||
TargetIDs coretypes.ResourceIDsExtractor
|
||||
TargetSelector coretypes.SelectorFunc
|
||||
// OptionalTargets drops the def when no target ids resolve, for routes where
|
||||
// the target list is legitimately optional in the payload.
|
||||
OptionalTargets bool
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
if def.OptionalTargets && def.TargetIDs.IsPhase(coretypes.PhaseRequest) {
|
||||
// extractors are pure; on error fall through and let fill record it
|
||||
ids, err := def.TargetIDs.Fn(ec)
|
||||
if err == nil && len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return []coretypes.ResolvedResource{
|
||||
coretypes.NewResolvedResourceWithTarget(
|
||||
def.Verb,
|
||||
|
||||
68
pkg/http/handler/resourcedef_test.go
Normal file
68
pkg/http/handler/resourcedef_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func userRoleAttachDef(optionalTargets bool) AttachDetachSiblingResourceDef {
|
||||
return AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.NewResourceRole(),
|
||||
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
|
||||
TargetSelector: coretypes.WildcardSelector,
|
||||
OptionalTargets: optionalTargets,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDetachSiblingResourceDefOptionalTargets(t *testing.T) {
|
||||
t.Run("absent target list resolves to no checks", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
assert.Empty(t, resolved)
|
||||
})
|
||||
|
||||
t.Run("empty target list resolves to no checks", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
assert.Empty(t, resolved)
|
||||
})
|
||||
|
||||
t.Run("present targets resolve the attach as usual", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":"role-a"},{"id":"role-b"}]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.NoError(t, resolved[0].Err())
|
||||
assert.Equal(t, []string{"role-a", "role-b"}, withTarget.TargetIDs())
|
||||
})
|
||||
|
||||
t.Run("malformed target entry still fails closed", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":""}]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
})
|
||||
|
||||
t.Run("without the flag the empty-id contract is preserved", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(false)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
})
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ authz.AuthZ }{},
|
||||
struct{ organization.Handler }{},
|
||||
struct{ user.Handler }{},
|
||||
struct{ user.Getter }{},
|
||||
struct{ session.Handler }{},
|
||||
struct{ authdomain.Handler }{},
|
||||
struct{ authdomain.Module }{},
|
||||
|
||||
@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddUserTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,6 +327,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
authz,
|
||||
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
|
||||
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
|
||||
modules.UserGetter,
|
||||
implsession.NewHandler(modules.Session, globalConfig),
|
||||
implauthdomain.NewHandler(modules.AuthDomain),
|
||||
modules.AuthDomain,
|
||||
|
||||
144
pkg/sqlmigration/127_add_user_tuples.go
Normal file
144
pkg/sqlmigration/127_add_user_tuples.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addUserTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addUserTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) 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
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// user and factor-password moved from the legacy AdminAccess role gate to
|
||||
// CheckResources, which on enterprise requires real tuples -- existing orgs
|
||||
// never had these written, only new orgs get them from the registry at
|
||||
// bootstrap. The managed-role transaction groups stored per org already
|
||||
// carry these transactions, so no re-sync is needed here.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
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, tuple.objectType, objectID, tuple.relation, user, "userset", 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, tuple.objectType, objectID, tuple.relation, user, 0, 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, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", 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, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user