mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-08 11:30:32 +01:00
Compare commits
8 Commits
platform-p
...
tvats-pkg-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411d3e64a4 | ||
|
|
078b82e957 | ||
|
|
72036b42e3 | ||
|
|
9301b2fb1c | ||
|
|
6d0e60822c | ||
|
|
acdaef6c2e | ||
|
|
a7690bdaa2 | ||
|
|
a7fde606ca |
@@ -66,10 +66,9 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
registry := coretypes.NewRegistry()
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.1
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.1
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -448,7 +448,6 @@ components:
|
||||
- delete
|
||||
- list
|
||||
- assignee
|
||||
- attach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
@@ -9378,9 +9377,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:list
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:list
|
||||
- ADMIN
|
||||
summary: List service accounts
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9440,9 +9439,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:create
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:create
|
||||
- ADMIN
|
||||
summary: Create service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9490,9 +9489,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:delete
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:delete
|
||||
- ADMIN
|
||||
summary: Deletes a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9547,9 +9546,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
summary: Gets a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9607,9 +9606,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
summary: Updates a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9661,9 +9660,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
summary: List service account keys
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9729,9 +9728,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
summary: Create a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9784,9 +9783,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
summary: Revoke a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9849,9 +9848,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- ADMIN
|
||||
summary: Updates a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9910,9 +9909,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
- ADMIN
|
||||
summary: Gets service account roles
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -9972,11 +9971,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -10023,11 +10020,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
|
||||
@@ -1839,7 +1839,6 @@ export enum AuthtypesRelationDTO {
|
||||
delete = 'delete',
|
||||
list = 'list',
|
||||
assignee = 'assignee',
|
||||
attach = 'attach',
|
||||
}
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,43 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Radio, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
@@ -173,9 +173,36 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
|
||||
158
frontend/src/hooks/dashboard/useDashboardBootstrap.test.tsx
Normal file
158
frontend/src/hooks/dashboard/useDashboardBootstrap.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Modal } from 'antd';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useDashboardQuery } from './useDashboardQuery';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSetDashboardData = jest.fn();
|
||||
const mockSetLayouts = jest.fn();
|
||||
const mockSetPanelMap = jest.fn();
|
||||
const mockResetDashboardStore = jest.fn();
|
||||
const mockGetUrlVariables = jest.fn();
|
||||
const mockUpdateUrlVariable = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1710000000000000000,
|
||||
maxTime: 1710000300000000000,
|
||||
isAutoRefreshDisabled: true,
|
||||
};
|
||||
|
||||
let currentQueryData: unknown;
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: (): { t: (key: string) => string } => ({
|
||||
t: (key: string): string => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn(() => mockDispatch),
|
||||
useSelector: jest.fn(
|
||||
(
|
||||
selectorFn: (state: { globalTime: typeof mockGlobalTime }) => unknown,
|
||||
): unknown => selectorFn({ globalTime: mockGlobalTime }),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useTabFocus', () => jest.fn(() => true));
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesSync', () => ({
|
||||
useDashboardVariablesSync: jest.fn(),
|
||||
}));
|
||||
jest.mock('./useDashboardQuery', () => ({
|
||||
useDashboardQuery: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/dashboard/useTransformDashboardVariables', () => ({
|
||||
useTransformDashboardVariables: jest.fn(),
|
||||
}));
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(),
|
||||
}));
|
||||
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
|
||||
initializeDefaultVariables: jest.fn(),
|
||||
}));
|
||||
jest.mock('lib/dashboard/getUpdatedLayout', () => ({
|
||||
getUpdatedLayout: jest.fn(() => []),
|
||||
}));
|
||||
jest.mock('providers/Dashboard/util', () => ({
|
||||
sortLayout: jest.fn((layout) => layout),
|
||||
}));
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
getMinMaxForSelectedTime: jest.fn(),
|
||||
}));
|
||||
|
||||
function TestComponent({ confirm }: { confirm: typeof Modal.confirm }): null {
|
||||
useDashboardBootstrap('dashboard-1', { confirm });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useDashboardBootstrap', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1710000000000000000,
|
||||
maxTime: 1710000300000000000,
|
||||
isAutoRefreshDisabled: true,
|
||||
};
|
||||
|
||||
jest.mocked(useDashboardStore as unknown as jest.Mock).mockReturnValue({
|
||||
setDashboardData: mockSetDashboardData,
|
||||
setLayouts: mockSetLayouts,
|
||||
setPanelMap: mockSetPanelMap,
|
||||
resetDashboardStore: mockResetDashboardStore,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(useTransformDashboardVariables as unknown as jest.Mock)
|
||||
.mockReturnValue({
|
||||
getUrlVariables: mockGetUrlVariables,
|
||||
updateUrlVariable: mockUpdateUrlVariable,
|
||||
transformDashboardVariables: <T,>(data: T): T => data,
|
||||
});
|
||||
|
||||
jest.mocked(useTabVisibility as unknown as jest.Mock).mockReturnValue(true);
|
||||
jest
|
||||
.mocked(useDashboardQuery as unknown as jest.Mock)
|
||||
.mockImplementation(() => ({
|
||||
data: currentQueryData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps minTime and maxTime unchanged for custom range on refresh confirm', () => {
|
||||
const initialDashboard = {
|
||||
id: 'dashboard-1',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
data: { layout: [], panelMap: {}, variables: {} },
|
||||
};
|
||||
|
||||
const updatedDashboard = {
|
||||
id: 'dashboard-1',
|
||||
updatedAt: '2024-01-01T01:00:00.000Z',
|
||||
data: { layout: [], panelMap: {}, variables: {} },
|
||||
};
|
||||
|
||||
const mockConfirm = jest.fn<
|
||||
ReturnType<typeof Modal.confirm>,
|
||||
Parameters<typeof Modal.confirm>
|
||||
>(() => ({ destroy: jest.fn(), update: jest.fn() }));
|
||||
|
||||
currentQueryData = { data: initialDashboard };
|
||||
const { rerender } = render(<TestComponent confirm={mockConfirm} />);
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
currentQueryData = { data: updatedDashboard };
|
||||
rerender(<TestComponent confirm={mockConfirm} />);
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
const firstCall = mockConfirm.mock.calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
const [confirmProps] = firstCall as Parameters<typeof Modal.confirm>;
|
||||
|
||||
act(() => {
|
||||
confirmProps.onOk?.();
|
||||
});
|
||||
|
||||
expect(getMinMaxForSelectedTime).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'UPDATE_TIME_INTERVAL',
|
||||
payload: {
|
||||
selectedTime: 'custom',
|
||||
minTime: mockGlobalTime.minTime,
|
||||
maxTime: mockGlobalTime.maxTime,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,11 +102,19 @@ export function useDashboardBootstrap(
|
||||
onOk() {
|
||||
setDashboardData(updatedDashboardData);
|
||||
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
const { maxTime, minTime } =
|
||||
globalTime.selectedTime === 'custom'
|
||||
? {
|
||||
// For custom ranges, min/max are already stored in nanoseconds.
|
||||
// Recomputing via getMinMaxForSelectedTime would multiply them again.
|
||||
maxTime: globalTime.maxTime,
|
||||
minTime: globalTime.minTime,
|
||||
}
|
||||
: getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },
|
||||
|
||||
@@ -7,10 +7,6 @@ export default {
|
||||
kind: 'role',
|
||||
type: 'metaresources',
|
||||
},
|
||||
{
|
||||
kind: 'serviceaccount',
|
||||
type: 'metaresources',
|
||||
},
|
||||
{
|
||||
kind: 'role',
|
||||
type: 'role',
|
||||
|
||||
@@ -24,10 +24,15 @@ export default function Tooltip({
|
||||
);
|
||||
|
||||
const showHeader = showTooltipHeader || activeItem != null;
|
||||
// With a single series the active item is fully represented in the header —
|
||||
// hide the divider and list to avoid showing a duplicate row.
|
||||
const showList = tooltipContent.length > 1;
|
||||
const showDivider = showList && showHeader;
|
||||
// A single row collapses into the header when it's the active item, but
|
||||
// must stay in the list when there's no active item (e.g. sync-driven
|
||||
// tooltips with no focused series) — otherwise the row would vanish.
|
||||
const showList =
|
||||
tooltipContent.length > 1 ||
|
||||
(tooltipContent.length === 1 && activeItem == null);
|
||||
// The divider separates the active row in the header from the list; with
|
||||
// no active item it has nothing to separate.
|
||||
const showDivider = showList && showHeader && activeItem != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -137,7 +137,7 @@ function applyReceiverSync({
|
||||
|
||||
if (commonKeys.length === 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
if ((uPlotInstance.cursor.left ?? -1) < 0) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import afterLogin from 'AppRoutes/utils';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowRight, CircleAlert } from 'lucide-react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import tvUrl from '@/assets/svgs/tv.svg';
|
||||
@@ -28,9 +28,8 @@ type FormValues = {
|
||||
|
||||
function SignUp(): JSX.Element {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
|
||||
|
||||
const [confirmPasswordError, setConfirmPasswordError] =
|
||||
useState<boolean>(false);
|
||||
const [formError, setFormError] = useState<APIError | null>();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
@@ -84,35 +83,10 @@ function SignUp(): JSX.Element {
|
||||
})();
|
||||
};
|
||||
|
||||
const handleValuesChange: (changedValues: Partial<FormValues>) => void = (
|
||||
changedValues,
|
||||
) => {
|
||||
// Clear error if passwords match while typing (but don't set error until blur)
|
||||
if ('password' in changedValues || 'confirmPassword' in changedValues) {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
const isPasswordMismatch =
|
||||
Boolean(confirmPassword) && password !== confirmPassword;
|
||||
|
||||
if (password && confirmPassword && password === confirmPassword) {
|
||||
setConfirmPasswordError(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordBlur = (): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
// Only validate if confirm password has a value
|
||||
if (confirmPassword) {
|
||||
const isSamePassword = password === confirmPassword;
|
||||
setConfirmPasswordError(!isSamePassword);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPasswordBlur = (): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
if (password && confirmPassword) {
|
||||
const isSamePassword = password === confirmPassword;
|
||||
setConfirmPasswordError(!isSamePassword);
|
||||
}
|
||||
};
|
||||
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
|
||||
|
||||
const isValidForm = useMemo(
|
||||
(): boolean =>
|
||||
@@ -120,8 +94,8 @@ function SignUp(): JSX.Element {
|
||||
Boolean(email?.trim()) &&
|
||||
Boolean(password?.trim()) &&
|
||||
Boolean(confirmPassword?.trim()) &&
|
||||
!confirmPasswordError,
|
||||
[loading, email, password, confirmPassword, confirmPasswordError],
|
||||
password === confirmPassword,
|
||||
[loading, email, password, confirmPassword],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -140,12 +114,7 @@ function SignUp(): JSX.Element {
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<FormContainer
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleValuesChange}
|
||||
form={form}
|
||||
className="signup-form"
|
||||
>
|
||||
<FormContainer onFinish={handleSubmit} form={form} className="signup-form">
|
||||
<div className="signup-form-container">
|
||||
<div className="signup-form-fields">
|
||||
<div className="signup-field-container">
|
||||
@@ -175,7 +144,6 @@ function SignUp(): JSX.Element {
|
||||
placeholder="Enter new password"
|
||||
disabled={loading}
|
||||
className="signup-antd-input"
|
||||
onBlur={handlePasswordBlur}
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
@@ -185,6 +153,12 @@ function SignUp(): JSX.Element {
|
||||
<FormContainer.Item
|
||||
name="confirmPassword"
|
||||
validateTrigger="onBlur"
|
||||
validateStatus={showPasswordMismatchError ? 'error' : undefined}
|
||||
help={
|
||||
showPasswordMismatchError
|
||||
? "Passwords don't match. Please try again."
|
||||
: undefined
|
||||
}
|
||||
rules={[{ required: true, message: 'Please enter confirm password!' }]}
|
||||
>
|
||||
<AntdInput.Password
|
||||
@@ -193,7 +167,7 @@ function SignUp(): JSX.Element {
|
||||
placeholder="Confirm your new password"
|
||||
disabled={loading}
|
||||
className="signup-antd-input"
|
||||
onBlur={handleConfirmPasswordBlur}
|
||||
onBlur={() => setConfirmPasswordTouched(true)}
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
@@ -205,19 +179,7 @@ function SignUp(): JSX.Element {
|
||||
your admin for an invite link
|
||||
</Callout>
|
||||
|
||||
{confirmPasswordError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="signup-error-callout"
|
||||
>
|
||||
Passwords don't match. Please try again.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{formError && !confirmPasswordError && <AuthError error={formError} />}
|
||||
{formError && <AuthError error={formError} />}
|
||||
|
||||
<div className="signup-form-actions">
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,12 @@ export const topTracesTableColumns = [
|
||||
dataIndex: 'trace_id',
|
||||
key: 'trace_id',
|
||||
render: (traceId: string): JSX.Element => (
|
||||
<Link to={`/trace/${traceId}`} className="trace-id-cell">
|
||||
<Link
|
||||
to={`/trace/${traceId}`}
|
||||
className="trace-id-cell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{traceId}
|
||||
</Link>
|
||||
),
|
||||
|
||||
@@ -60,7 +60,6 @@ type provider struct {
|
||||
rawDataExportHandler rawdataexport.Handler
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountModule serviceaccount.Module
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
@@ -93,7 +92,6 @@ func NewFactory(
|
||||
rawDataExportHandler rawdataexport.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountModule serviceaccount.Module,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
@@ -129,7 +127,6 @@ func NewFactory(
|
||||
rawDataExportHandler,
|
||||
zeusHandler,
|
||||
querierHandler,
|
||||
serviceAccountModule,
|
||||
serviceAccountHandler,
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
@@ -167,7 +164,6 @@ func newProvider(
|
||||
rawDataExportHandler rawdataexport.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountModule serviceaccount.Module,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
@@ -203,7 +199,6 @@ func newProvider(
|
||||
rawDataExportHandler: rawDataExportHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountModule: serviceAccountModule,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
@@ -341,7 +336,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
}
|
||||
|
||||
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
|
||||
return newScopedSecuritySchemes([]string{role.String()})
|
||||
return []handler.OpenAPISecurityScheme{
|
||||
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: []string{role.String()}},
|
||||
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}},
|
||||
}
|
||||
}
|
||||
|
||||
func newAnonymousSecuritySchemes(scopes []string) []handler.OpenAPISecurityScheme {
|
||||
@@ -349,10 +347,3 @@ func newAnonymousSecuritySchemes(scopes []string) []handler.OpenAPISecuritySchem
|
||||
{Name: authtypes.IdentNProviderAnonymous.StringValue(), Scopes: scopes},
|
||||
}
|
||||
}
|
||||
|
||||
func newScopedSecuritySchemes(scopes []string) []handler.OpenAPISecurityScheme {
|
||||
return []handler.OpenAPISecurityScheme{
|
||||
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: scopes},
|
||||
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: scopes},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,14 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"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/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceMetaResourcesServiceAccount, serviceAccountCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
@@ -28,14 +23,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcesServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourcesServiceAccount, serviceAccountCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccounts",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service accounts",
|
||||
@@ -47,7 +40,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcesServiceAccount.Scope(coretypes.VerbList)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,9 +62,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets a service account",
|
||||
@@ -83,14 +74,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRoles",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets service account roles",
|
||||
@@ -102,14 +91,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.SetRole, authtypes.Relation{Verb: coretypes.VerbAttach}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
@@ -121,19 +108,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromPath, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
@@ -145,7 +125,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,9 +147,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account",
|
||||
@@ -181,14 +159,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Deletes a service account",
|
||||
@@ -200,14 +176,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create a service account key",
|
||||
@@ -219,14 +193,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccountKeys",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service account keys",
|
||||
@@ -238,14 +210,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account key",
|
||||
@@ -257,14 +227,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "RevokeServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Revoke a service account key",
|
||||
@@ -276,38 +244,10 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) roleAttachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.serviceAccountModule.RoleAttachSelectors(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
}
|
||||
|
||||
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeMetaResources.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
id := mux.Vars(req)["id"]
|
||||
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
idSelector,
|
||||
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
99
pkg/errors/v2/code.go
Normal file
99
pkg/errors/v2/code.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Code is a dotted, hierarchical identifier registered at process start. It
|
||||
// encodes domain (subsystem), op (verb), optional sub (qualifier), and a
|
||||
// terminal reason. Codes are values; two Codes with the same string are equal
|
||||
// by value and safe to compare with ==.
|
||||
type Code struct{ s string }
|
||||
|
||||
// String returns the dotted code as it appears on the wire. Empty for the
|
||||
// zero value.
|
||||
func (c Code) String() string { return c.s }
|
||||
|
||||
// codePattern allows 2-4 dotted segments, each starting with a lowercase
|
||||
// letter and continuing with [a-z0-9_]. One segment is too broad (use a
|
||||
// domain prefix); five or more means the domain should be split.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$`)
|
||||
|
||||
// Meta is the per-code default envelope applied by constructors before
|
||||
// per-call options. Every field has a natural per-code default — an auth
|
||||
// code always wants Reauthenticate, every documented code wants its docs
|
||||
// URL — so the registry is the right place to declare them once.
|
||||
type Meta struct {
|
||||
Category Category
|
||||
Fault Fault
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
}
|
||||
|
||||
// Retry tells the caller how and when to retry. After is meaningful only
|
||||
// when Policy == RetryAfter.
|
||||
type Retry struct {
|
||||
Policy RetryPolicy
|
||||
After time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]Meta{}
|
||||
)
|
||||
|
||||
// Register installs a code with its default Meta and returns the Code value.
|
||||
// It panics on a malformed code string or a duplicate registration — both
|
||||
// indicate a programming error that must be caught at boot, not at first
|
||||
// failure.
|
||||
//
|
||||
// Call from the owning domain's package init or top-level var block:
|
||||
//
|
||||
// var CodeUnknownFunction = errors.Register("query.parse.unknown_function", errors.Meta{
|
||||
// Category: errors.CategoryInvalidInput,
|
||||
// Fault: errors.FaultCaller,
|
||||
// Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
// })
|
||||
func Register(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; ok {
|
||||
panic("errors/v2: duplicate code: " + s)
|
||||
}
|
||||
registry[s] = meta
|
||||
return Code{s: s}
|
||||
}
|
||||
|
||||
// MetaOf returns the Meta a code was registered with. Returns the zero Meta
|
||||
// and false for unregistered or zero codes.
|
||||
func MetaOf(c Code) (Meta, bool) {
|
||||
if c.s == "" {
|
||||
return Meta{}, false
|
||||
}
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
m, ok := registry[c.s]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// registerOrGet is the internal idempotent register used by adapters that
|
||||
// may see the same code (e.g. legacy.<v1>) more than once across the process
|
||||
// lifetime. It panics on malformed codes — duplicate codes silently keep the
|
||||
// existing Meta.
|
||||
func registerOrGet(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; !ok {
|
||||
registry[s] = meta
|
||||
}
|
||||
return Code{s: s}
|
||||
}
|
||||
93
pkg/errors/v2/enums.go
Normal file
93
pkg/errors/v2/enums.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package v2
|
||||
|
||||
// The enums in this file are closed sets. Each value is a package-level var of
|
||||
// an unexported-field struct, so external code cannot synthesize new values —
|
||||
// it must reference one of the defined ones. String() returns the stable
|
||||
// snake_case wire name; once shipped, those names are append-only.
|
||||
|
||||
// Category groups errors by what kind of failure occurred. It is the coarsest
|
||||
// branch-worthy axis and is intended to be a superset of gRPC status codes
|
||||
// extended with cases SigNoz cares about (e.g. license issues land under
|
||||
// FailedDependency or ResourceExhausted depending on context).
|
||||
type Category struct{ s string }
|
||||
|
||||
func (c Category) String() string { return c.s }
|
||||
|
||||
var (
|
||||
CategoryInvalidInput = Category{"invalid_input"} // request was malformed or violated a documented constraint.
|
||||
CategoryNotFound = Category{"not_found"} // referenced resource does not exist.
|
||||
CategoryAlreadyExists = Category{"already_exists"} // resource creation conflicts with an existing one.
|
||||
CategoryConflict = Category{"conflict"} // concurrent modification or state mismatch (e.g. stale revision).
|
||||
CategoryPrecondition = Category{"precondition"} // a required precondition (system or caller-asserted) was not met.
|
||||
CategoryUnauthenticated = Category{"unauthenticated"} // credentials are missing or invalid.
|
||||
CategoryForbidden = Category{"forbidden"} // authenticated but not authorized for this action.
|
||||
CategoryResourceExhausted = Category{"resource_exhausted"} // quota, rate limit, or other budget exceeded.
|
||||
CategoryFailedDependency = Category{"failed_dependency"} // an upstream service we depend on failed (db, license, etc.).
|
||||
CategoryUnavailable = Category{"unavailable"} // service is temporarily down; retry with backoff.
|
||||
CategoryTimeout = Category{"timeout"} // deadline exceeded before the operation completed.
|
||||
CategoryCanceled = Category{"canceled"} // caller or context canceled the operation.
|
||||
CategoryUnimplemented = Category{"unimplemented"} // operation is not supported (or not yet) by this server.
|
||||
CategoryDataLoss = Category{"data_loss"} // unrecoverable data corruption or loss detected.
|
||||
CategoryInternal = Category{"internal"} // bug — invariant broken; should not occur in normal operation.
|
||||
)
|
||||
|
||||
// Fault attributes responsibility. An agent uses this to decide whether to
|
||||
// fix the request (Caller), retry/escalate (Server, Upstream), or page a
|
||||
// human (Operator).
|
||||
type Fault struct{ s string }
|
||||
|
||||
func (f Fault) String() string { return f.s }
|
||||
|
||||
var (
|
||||
FaultCaller = Fault{"caller"}
|
||||
FaultServer = Fault{"server"}
|
||||
FaultUpstream = Fault{"upstream"}
|
||||
FaultOperator = Fault{"operator"}
|
||||
)
|
||||
|
||||
// RetryPolicy tells the caller how to behave on retry. Backoff implies the
|
||||
// caller should use its own backoff schedule; After means honor Retry.After
|
||||
// exactly; AfterFix and AfterAuth signal that retry is pointless until the
|
||||
// caller fixes the request or re-authenticates.
|
||||
type RetryPolicy struct{ s string }
|
||||
|
||||
func (r RetryPolicy) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RetryNever = RetryPolicy{"never"}
|
||||
RetryImmediate = RetryPolicy{"immediate"}
|
||||
RetryBackoff = RetryPolicy{"backoff"}
|
||||
RetryAfter = RetryPolicy{"after"}
|
||||
RetryAfterFix = RetryPolicy{"after_fix"}
|
||||
RetryAfterAuth = RetryPolicy{"after_auth"}
|
||||
)
|
||||
|
||||
// Remediation names the single recommended next action. It does not execute.
|
||||
type Remediation struct{ s string }
|
||||
|
||||
func (r Remediation) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RemediationNone = Remediation{"none"}
|
||||
RemediationFixInput = Remediation{"fix_input"}
|
||||
RemediationReauthenticate = Remediation{"reauthenticate"}
|
||||
RemediationWaitAndRetry = Remediation{"wait_and_retry"}
|
||||
RemediationFailover = Remediation{"failover"}
|
||||
RemediationContactOperator = Remediation{"contact_operator"}
|
||||
RemediationFileBug = Remediation{"file_bug"}
|
||||
RemediationUpgradeLicense = Remediation{"upgrade_license"}
|
||||
)
|
||||
|
||||
// RefKind classifies a reference URL attached to the error.
|
||||
type RefKind struct{ s string }
|
||||
|
||||
func (r RefKind) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RefDocs = RefKind{"docs"}
|
||||
RefRunbook = RefKind{"runbook"}
|
||||
RefDashboard = RefKind{"dashboard"}
|
||||
RefTrace = RefKind{"trace"}
|
||||
RefSource = RefKind{"source"}
|
||||
RefIssue = RefKind{"issue"}
|
||||
)
|
||||
248
pkg/errors/v2/error.go
Normal file
248
pkg/errors/v2/error.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Package v2 is the redesigned pkg/errors.
|
||||
//
|
||||
// Every branch-worthy field on the Error struct is a closed enum and every
|
||||
// variable part is a typed key/value. The intent is to make errors first-class
|
||||
// data for programmatic consumers — SDK clients, UI surfaces, alerting, and
|
||||
// LLM agents — without sacrificing human readability.
|
||||
//
|
||||
// Domain and op are encoded into Code (e.g. "query.parse.unknown_function")
|
||||
// rather than carried as separate struct fields. Frames[0] is the
|
||||
// authoritative call-site location, captured at construction time.
|
||||
package v2
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Error is the redesigned error value. *Error is the canonical form passed
|
||||
// around — the zero value is unused, construct via New / Newf / Wrap / Wrapf.
|
||||
//
|
||||
// Frames are intentionally not a struct field: resolving captured PCs into
|
||||
// func/file/line is the dominant construction cost, so we capture PCs eagerly
|
||||
// at construction time (so the snapshot is faithful to the call site) and
|
||||
// resolve them lazily via Frames() only when something actually inspects them.
|
||||
type Error struct {
|
||||
// WHAT
|
||||
Category Category
|
||||
Code Code
|
||||
Title string
|
||||
Detail string
|
||||
|
||||
// WHY / WHO
|
||||
Cause error
|
||||
Fault Fault
|
||||
|
||||
// WHAT NEXT
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
|
||||
// CONTEXT
|
||||
Attrs map[string]any
|
||||
TraceID string
|
||||
SpanID string
|
||||
|
||||
// stack is the captured PCs plus a memoized []Frame; never read directly,
|
||||
// always go through Frames().
|
||||
stack *frameStack
|
||||
}
|
||||
|
||||
// Frames returns the captured stack, resolved to func/file/line on first
|
||||
// access. Frames[0] is the constructor's caller. Safe for concurrent use.
|
||||
func (e *Error) Frames() []Frame {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.stack.frames()
|
||||
}
|
||||
|
||||
// New creates an Error for a registered Code. Defaults from the registered
|
||||
// Meta are applied first; opts override per call site.
|
||||
func New(code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Newf is New with fmt.Sprintf-style formatting for the title.
|
||||
func Newf(code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrap creates an Error that wraps cause. The new error's Title is the
|
||||
// caller-supplied title (not the cause's message), so Error() reports what
|
||||
// went wrong at this layer — the cause is reachable via Unwrap.
|
||||
func Wrap(cause error, code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrapf is Wrap with fmt.Sprintf-style formatting for the title.
|
||||
func Wrapf(cause error, code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// applyMeta copies default values from the registered Meta into a fresh
|
||||
// Error. It runs before per-call options so options win.
|
||||
func applyMeta(e *Error) {
|
||||
meta, ok := MetaOf(e.Code)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if (e.Category == Category{}) {
|
||||
e.Category = meta.Category
|
||||
}
|
||||
if (e.Fault == Fault{}) {
|
||||
e.Fault = meta.Fault
|
||||
}
|
||||
if (e.Retry == Retry{}) {
|
||||
e.Retry = meta.Retry
|
||||
}
|
||||
if (e.Remediation == Remediation{}) {
|
||||
e.Remediation = meta.Remediation
|
||||
}
|
||||
if len(meta.Refs) > 0 {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, len(meta.Refs))
|
||||
}
|
||||
for k, v := range meta.Refs {
|
||||
if _, exists := e.Refs[k]; !exists {
|
||||
e.Refs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the Title (the message specifically attached at this wrap
|
||||
// site), not the cause's message. This fixes the v1 surprise where Error()
|
||||
// returned the wrapped cause's text.
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return e.Title
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped cause, enabling errors.Is / errors.As.
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Format implements fmt.Formatter.
|
||||
//
|
||||
// %s, %v → Title only
|
||||
// %+v → full chain: code, title, frames, attrs, recursive cause
|
||||
func (e *Error) Format(f fmt.State, verb rune) {
|
||||
switch verb {
|
||||
case 's':
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'v':
|
||||
if f.Flag('+') {
|
||||
_, _ = io.WriteString(f, e.fullString())
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'q':
|
||||
fmt.Fprintf(f, "%q", e.Title)
|
||||
default:
|
||||
fmt.Fprintf(f, "%%!%c(*errors/v2.Error)", verb)
|
||||
}
|
||||
}
|
||||
|
||||
// fullString produces the %+v rendering. Format is intentionally
|
||||
// human-readable rather than machine-parseable; consumers that want structure
|
||||
// should marshal to JSON.
|
||||
func (e *Error) fullString() string {
|
||||
var b strings.Builder
|
||||
e.appendFull(&b, 0)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Error) appendFull(b *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
fmt.Fprintf(b, "%s[%s] %s\n", indent, e.Code.s, e.Title)
|
||||
if e.Detail != "" {
|
||||
fmt.Fprintf(b, "%s detail: %s\n", indent, e.Detail)
|
||||
}
|
||||
if len(e.Attrs) > 0 {
|
||||
// Stable key order for deterministic output.
|
||||
keys := make([]string, 0, len(e.Attrs))
|
||||
for k := range e.Attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%s attrs:\n", indent)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(b, "%s %s=%v\n", indent, k, e.Attrs[k])
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
fmt.Fprintf(b, "%s frames:\n", indent)
|
||||
for _, fr := range frames {
|
||||
fmt.Fprintf(b, "%s %s\n%s %s:%s\n", indent, fr.Func, indent, fr.File, strconv.Itoa(fr.Line))
|
||||
}
|
||||
}
|
||||
if e.Cause != nil {
|
||||
fmt.Fprintf(b, "%scaused by:\n", indent)
|
||||
var ce *Error
|
||||
if stderrors.As(e.Cause, &ce) && ce != nil {
|
||||
ce.appendFull(b, depth+1)
|
||||
} else {
|
||||
fmt.Fprintf(b, "%s %s\n", indent, e.Cause.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AsError extracts a *Error from anywhere in err's wrap chain. It is the
|
||||
// common shortcut around errors.As for code that always wants this package's
|
||||
// type.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
var e *Error
|
||||
if stderrors.As(err, &e) {
|
||||
return e, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Is reports whether err or any error in its chain has the given Code.
|
||||
// Convenience wrapper that's friendlier than errors.As at call sites that
|
||||
// only care about code identity.
|
||||
func Is(err error, code Code) bool {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for e != nil {
|
||||
if e.Code == code {
|
||||
return true
|
||||
}
|
||||
next, ok := AsError(e.Cause)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = next
|
||||
}
|
||||
return false
|
||||
}
|
||||
90
pkg/errors/v2/example.go
Normal file
90
pkg/errors/v2/example.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package v2
|
||||
|
||||
// This file is a self-contained walkthrough of how a domain integrates with
|
||||
// pkg/errors/v2. It mirrors what a real pkg/<domain>/errors.go looks like in
|
||||
// practice — registering codes, constructing typed errors at failure sites,
|
||||
// and consuming them at API boundaries. The "example.*" namespace is reserved
|
||||
// for these demo codes so they never collide with a real domain's
|
||||
// registrations.
|
||||
|
||||
// 1. Register codes at package init time. Each Register call panics on
|
||||
// malformed code or duplicate registration, so misconfiguration is caught
|
||||
// at process boot, not at first failure.
|
||||
var (
|
||||
// A caller-fault, fix-the-input error: rejected before any work happens.
|
||||
exampleCodeInvalidQuery = Register("example.query.invalid_filter", Meta{
|
||||
Category: CategoryInvalidInput,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationFixInput,
|
||||
Retry: Retry{Policy: RetryAfterFix},
|
||||
Refs: map[RefKind]string{
|
||||
RefDocs: "https://signoz.io/docs/query/filters",
|
||||
},
|
||||
})
|
||||
|
||||
// A quota error: the caller's request was well-formed but their plan
|
||||
// doesn't allow it. The recommended remediation is structural (upgrade),
|
||||
// not "try again later."
|
||||
exampleCodeQuotaExceeded = Register("example.billing.quota_exceeded", Meta{
|
||||
Category: CategoryResourceExhausted,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationUpgradeLicense,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
)
|
||||
|
||||
// 2. Construct errors at the failure site. Notice that variable parts of
|
||||
// the message (the offending field, the limits) live in typed Attrs, not in
|
||||
// the title prose — a downstream agent can read them without parsing English.
|
||||
func exampleRejectInvalidFilter(field string) *Error {
|
||||
return New(exampleCodeInvalidQuery, "filter is not supported",
|
||||
WithAttr("field", field),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Consume errors at the API boundary. Branching on Category gives the
|
||||
// HTTP status; Retry tells an SDK how to behave; Fault drives logging
|
||||
// classification (caller errors are warnings, server/upstream errors page).
|
||||
func exampleClassifyForHTTP(err error) (status int, retry RetryPolicy) {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return 500, RetryNever
|
||||
}
|
||||
switch e.Category {
|
||||
case CategoryInvalidInput, CategoryPrecondition:
|
||||
status = 400
|
||||
case CategoryUnauthenticated:
|
||||
status = 401
|
||||
case CategoryForbidden:
|
||||
status = 403
|
||||
case CategoryNotFound:
|
||||
status = 404
|
||||
case CategoryConflict, CategoryAlreadyExists:
|
||||
status = 409
|
||||
case CategoryResourceExhausted:
|
||||
status = 429
|
||||
case CategoryUnavailable, CategoryTimeout:
|
||||
status = 503
|
||||
case CategoryUnimplemented:
|
||||
status = 501
|
||||
default:
|
||||
status = 500
|
||||
}
|
||||
return status, e.Retry.Policy
|
||||
}
|
||||
|
||||
// 4. Identify a specific failure mode by Code. Is walks the cause chain so
|
||||
// a wrapper at the HTTP layer still matches when the root cause was raised
|
||||
// deep in the call graph.
|
||||
func exampleIsQuotaExceeded(err error) bool {
|
||||
return Is(err, exampleCodeQuotaExceeded)
|
||||
}
|
||||
|
||||
// The example helpers are reference-only: they exist to document call-site
|
||||
// patterns, not to be called from anywhere in the binary. This anchor keeps
|
||||
// them visible to readers (and the linter) without exporting demo code.
|
||||
var _ = []any{
|
||||
exampleRejectInvalidFilter,
|
||||
exampleClassifyForHTTP,
|
||||
exampleIsQuotaExceeded,
|
||||
}
|
||||
65
pkg/errors/v2/frame.go
Normal file
65
pkg/errors/v2/frame.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Frame is a single line in the call stack. Frames[0] is the constructor's
|
||||
// caller — the authoritative "where this error came from" — and downstream
|
||||
// consumers can filter (e.g. "frames inside our code") without regex
|
||||
// reparsing of a pre-formatted stack string.
|
||||
type Frame struct {
|
||||
Func string `json:"func,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// frameStack carries the PCs captured at construction plus the resolved
|
||||
// []Frame slice, behind a sync.Once. Resolving frames into func/file/line is
|
||||
// expensive (runtime.CallersFrames walks the symbol table); the vast majority
|
||||
// of errors are constructed and never inspected, so we only pay that cost
|
||||
// when a consumer actually asks for frames (Frames()/MarshalJSON/%+v).
|
||||
//
|
||||
// The PC capture itself is cheap and happens at construction so that
|
||||
// Frames[0] is a faithful "where" record of the original call site.
|
||||
type frameStack struct {
|
||||
pcs []uintptr
|
||||
|
||||
once sync.Once
|
||||
resolved []Frame
|
||||
}
|
||||
|
||||
// captureStack is called by every constructor. skip drops runtime.Callers,
|
||||
// captureStack itself, and the constructor frame so that the first PC is the
|
||||
// user code that invoked the constructor.
|
||||
func captureStack(skip int) *frameStack {
|
||||
const depth = 32
|
||||
pcs := make([]uintptr, depth)
|
||||
n := runtime.Callers(skip, pcs)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &frameStack{pcs: pcs[:n:n]}
|
||||
}
|
||||
|
||||
// frames resolves the captured PCs into []Frame. The resolution is memoized
|
||||
// — concurrent calls are safe and only one of them does the work.
|
||||
func (s *frameStack) frames() []Frame {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.once.Do(func() {
|
||||
cf := runtime.CallersFrames(s.pcs)
|
||||
out := make([]Frame, 0, len(s.pcs))
|
||||
for {
|
||||
f, more := cf.Next()
|
||||
out = append(out, Frame{Func: f.Function, File: f.File, Line: f.Line})
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.resolved = out
|
||||
})
|
||||
return s.resolved
|
||||
}
|
||||
175
pkg/errors/v2/http.go
Normal file
175
pkg/errors/v2/http.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// CodeUnknown is the sentinel returned when AsJSON / AsURLValues are called
|
||||
// on a non-*Error. A consumer that sees this on the wire should read it as
|
||||
// "the producer did not raise a v2 Error and we projected it through the
|
||||
// fallback path" — i.e. somewhere upstream is still using std errors or v1.
|
||||
var CodeUnknown = Register("unknown.unset", Meta{
|
||||
Category: CategoryInternal,
|
||||
Fault: FaultServer,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
|
||||
// JSON is the wire envelope for an Error. It is intentionally a superset of
|
||||
// v1's pkg/errors.JSON: SDK clients that only read v1's {code, message, url,
|
||||
// errors[]} keep working, while v2 consumers can branch on the new typed
|
||||
// fields (category, fault, retry, remediation, attrs, refs, cause).
|
||||
type JSON struct {
|
||||
Code string `json:"code" required:"true"`
|
||||
Title string `json:"title" required:"true"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Fault string `json:"fault,omitempty"`
|
||||
Retry *RetryJSON `json:"retry,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
Refs map[string]string `json:"refs,omitempty"`
|
||||
Frames []Frame `json:"frames,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// RetryJSON renders Retry as an object so consumers can branch on policy
|
||||
// before consulting AfterMS. AfterMS is omitted unless policy is "after".
|
||||
type RetryJSON struct {
|
||||
Policy string `json:"policy"`
|
||||
AfterMS int64 `json:"after_ms,omitempty"`
|
||||
}
|
||||
|
||||
// CauseJSON is the thin recursive shape for a cause chain. Only code, title,
|
||||
// and a nested cause are guaranteed — producers may add more, consumers must
|
||||
// not rely on it.
|
||||
type CauseJSON struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// AsJSON projects any error onto the v2 wire envelope. If cause is a
|
||||
// *Error (anywhere in its wrap chain) every field is filled from it;
|
||||
// otherwise the result is a CodeUnknown envelope with Title=cause.Error()
|
||||
// so the wire shape is always valid and never panics.
|
||||
func AsJSON(cause error) *JSON {
|
||||
if cause == nil {
|
||||
return nil
|
||||
}
|
||||
e, ok := AsError(cause)
|
||||
if !ok {
|
||||
return &JSON{
|
||||
Code: CodeUnknown.s,
|
||||
Title: cause.Error(),
|
||||
Category: CategoryInternal.s,
|
||||
Fault: FaultServer.s,
|
||||
}
|
||||
}
|
||||
return errorToJSON(e)
|
||||
}
|
||||
|
||||
func errorToJSON(e *Error) *JSON {
|
||||
out := &JSON{
|
||||
Code: e.Code.s,
|
||||
Title: e.Title,
|
||||
Detail: e.Detail,
|
||||
Category: e.Category.s,
|
||||
Fault: e.Fault.s,
|
||||
Remediation: e.Remediation.s,
|
||||
Attrs: e.Attrs,
|
||||
TraceID: e.TraceID,
|
||||
SpanID: e.SpanID,
|
||||
}
|
||||
if (e.Retry.Policy != RetryPolicy{}) {
|
||||
out.Retry = &RetryJSON{Policy: e.Retry.Policy.s}
|
||||
if e.Retry.Policy == RetryAfter && e.Retry.After > 0 {
|
||||
out.Retry.AfterMS = e.Retry.After.Milliseconds()
|
||||
}
|
||||
}
|
||||
if len(e.Refs) > 0 {
|
||||
out.Refs = make(map[string]string, len(e.Refs))
|
||||
for k, v := range e.Refs {
|
||||
out.Refs[k.s] = v
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
out.Frames = frames
|
||||
}
|
||||
if e.Cause != nil {
|
||||
out.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func causeToJSON(err error) *CauseJSON {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
c := &CauseJSON{Code: e.Code.s, Title: e.Title}
|
||||
if e.Cause != nil {
|
||||
c.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return c
|
||||
}
|
||||
// Non-*Error leaf: only Title is set, no Code.
|
||||
return &CauseJSON{Title: err.Error()}
|
||||
}
|
||||
|
||||
// AsURLValues projects an error onto a flat url.Values, matching v1's shape
|
||||
// for callers (e.g. OAuth/SSO redirects) that smuggle errors back through a
|
||||
// query string. Complex fields (attrs, refs, retry, frames, cause) are
|
||||
// JSON-marshaled into a single value rather than spread across multiple
|
||||
// keys, since query strings have no good representation for nested data.
|
||||
func AsURLValues(cause error) url.Values {
|
||||
j := AsJSON(cause)
|
||||
if j == nil {
|
||||
return url.Values{}
|
||||
}
|
||||
v := url.Values{
|
||||
"code": {j.Code},
|
||||
"title": {j.Title},
|
||||
}
|
||||
if j.Detail != "" {
|
||||
v.Set("detail", j.Detail)
|
||||
}
|
||||
if j.Category != "" {
|
||||
v.Set("category", j.Category)
|
||||
}
|
||||
if j.Fault != "" {
|
||||
v.Set("fault", j.Fault)
|
||||
}
|
||||
if j.Remediation != "" {
|
||||
v.Set("remediation", j.Remediation)
|
||||
}
|
||||
if j.TraceID != "" {
|
||||
v.Set("trace_id", j.TraceID)
|
||||
}
|
||||
if j.SpanID != "" {
|
||||
v.Set("span_id", j.SpanID)
|
||||
}
|
||||
if j.Retry != nil {
|
||||
if b, err := json.Marshal(j.Retry); err == nil {
|
||||
v.Set("retry", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Refs) > 0 {
|
||||
if b, err := json.Marshal(j.Refs); err == nil {
|
||||
v.Set("refs", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Attrs) > 0 {
|
||||
if b, err := json.Marshal(j.Attrs); err == nil {
|
||||
v.Set("attrs", string(b))
|
||||
}
|
||||
}
|
||||
if j.Cause != nil {
|
||||
if b, err := json.Marshal(j.Cause); err == nil {
|
||||
v.Set("cause", string(b))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
85
pkg/errors/v2/options.go
Normal file
85
pkg/errors/v2/options.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package v2
|
||||
|
||||
import "time"
|
||||
|
||||
// Option mutates an Error during construction. Options are applied after the
|
||||
// registered Meta defaults so a per-call WithFault wins over the code's
|
||||
// default Fault.
|
||||
type Option func(*Error)
|
||||
|
||||
// WithTitle overrides the title (used when Newf's formatted string is not
|
||||
// what you want, or after a Wrap that took its title from the cause).
|
||||
func WithTitle(s string) Option { return func(e *Error) { e.Title = s } }
|
||||
|
||||
// WithDetail adds a long, user-safe explanation. Detail must never include
|
||||
// raw cause text; the cause is already in the chain.
|
||||
func WithDetail(s string) Option { return func(e *Error) { e.Detail = s } }
|
||||
|
||||
// WithCategory overrides the registered Category.
|
||||
func WithCategory(c Category) Option { return func(e *Error) { e.Category = c } }
|
||||
|
||||
// WithFault overrides the registered Fault.
|
||||
func WithFault(f Fault) Option { return func(e *Error) { e.Fault = f } }
|
||||
|
||||
// WithRetry overrides the registered Retry.
|
||||
func WithRetry(r Retry) Option { return func(e *Error) { e.Retry = r } }
|
||||
|
||||
// WithRetryAfter is a convenience for the common RetryAfter case.
|
||||
func WithRetryAfter(d time.Duration) Option {
|
||||
return func(e *Error) { e.Retry = Retry{Policy: RetryAfter, After: d} }
|
||||
}
|
||||
|
||||
// WithRemediation overrides the registered Remediation.
|
||||
//
|
||||
// Convention for "did you mean" hints: stash a []string under
|
||||
// Attrs["suggestions"], ranked best-first. Each element should be a complete,
|
||||
// copy-pasteable replacement — not an explanation of what went wrong (use
|
||||
// WithDetail for that). Once 3-4 domains adopt the convention identically,
|
||||
// promote to a first-class field.
|
||||
func WithRemediation(r Remediation) Option { return func(e *Error) { e.Remediation = r } }
|
||||
|
||||
// WithRef adds (or replaces) a single reference URL keyed by kind.
|
||||
func WithRef(kind RefKind, url string) Option {
|
||||
return func(e *Error) {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, 1)
|
||||
}
|
||||
e.Refs[kind] = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttr sets a single typed attribute. Prefer typed per-domain helpers
|
||||
// (e.g. WithQueryAttrs(q Query)) over raw WithAttr at call sites — they keep
|
||||
// the attr keys consistent and let the compiler reject typos.
|
||||
func WithAttr(key string, value any) Option {
|
||||
return func(e *Error) {
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, 1)
|
||||
}
|
||||
e.Attrs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttrs merges a map of attributes; later keys win.
|
||||
func WithAttrs(attrs map[string]any) Option {
|
||||
return func(e *Error) {
|
||||
if len(attrs) == 0 {
|
||||
return
|
||||
}
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, len(attrs))
|
||||
}
|
||||
for k, v := range attrs {
|
||||
e.Attrs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrace stamps the error with OTel trace and span IDs so the JSON
|
||||
// response can link back to the originating span.
|
||||
func WithTrace(traceID, spanID string) Option {
|
||||
return func(e *Error) {
|
||||
e.TraceID = traceID
|
||||
e.SpanID = spanID
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"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"
|
||||
@@ -19,20 +18,6 @@ const (
|
||||
authzDeniedMessage string = "::AUTHZ-DENIED::"
|
||||
)
|
||||
|
||||
type AuthZCheckDef struct {
|
||||
Relation authtypes.Relation
|
||||
Resource coretypes.Resource
|
||||
SelectorCallback selectorCallbackWithClaimsFn
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// AuthZCheckGroup is a set of checks OR'd together.
|
||||
// At least one check in the group must pass for the group to pass.
|
||||
type AuthZCheckGroup []AuthZCheckDef
|
||||
|
||||
type selectorCallbackWithClaimsFn func(*http.Request, authtypes.Claims) ([]coretypes.Selector, error)
|
||||
type selectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
|
||||
|
||||
type AuthZ struct {
|
||||
logger *slog.Logger
|
||||
orgGetter organization.Getter
|
||||
@@ -201,7 +186,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb authtypes.SelectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -231,61 +216,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
})
|
||||
}
|
||||
|
||||
// CheckAll verifies groups of permission checks.
|
||||
// Within each group, checks are OR'd (any check passing = group passes).
|
||||
// Across groups, results are AND'd (all groups must pass).
|
||||
//
|
||||
// This model expresses any combination:
|
||||
// - Single check: []AuthZCheckGroup{{checkA}}
|
||||
// - Pure AND: []AuthZCheckGroup{{checkA}, {checkB}}
|
||||
// - Cross-resource OR: []AuthZCheckGroup{{checkA, checkB}}
|
||||
// - Mixed (A OR B) AND C: []AuthZCheckGroup{{checkA, checkB}, {checkC}}
|
||||
func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGroup) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
for _, group := range groups {
|
||||
groupPassed := false
|
||||
var lastErr error
|
||||
|
||||
for _, check := range group {
|
||||
selectors, err := check.SelectorCallback(req, claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleSelectors := make([]coretypes.Selector, len(check.Roles))
|
||||
for idx, role := range check.Roles {
|
||||
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, check.Relation, check.Resource, selectors, roleSelectors)
|
||||
if err == nil {
|
||||
groupPassed = true
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if !groupPassed {
|
||||
render.Error(rw, lastErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
|
||||
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb authtypes.SelectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
orgs, err := middleware.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
|
||||
@@ -376,43 +376,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (module *module) RoleAttachSelectors(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
role, err := module.authz.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(role.Name),
|
||||
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
|
||||
// Role-level attach check. The entity-level attach check (VerbAttach on the SA)
|
||||
// is done in the middleware. The role check lives here because the role ID comes
|
||||
// from the request body which is only available after the handler parses it.
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selectors, err := module.RoleAttachSelectors(ctx, orgID, role.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.CheckWithTupleCreation(
|
||||
ctx, claims, orgID,
|
||||
authtypes.Relation{Verb: coretypes.VerbAttach},
|
||||
coretypes.ResourceRole,
|
||||
selectors,
|
||||
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName)},
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "caller does not have permission to grant role %q", role.Name)
|
||||
}
|
||||
|
||||
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -467,4 +431,3 @@ func apiKeyCacheKey(apiKey string) string {
|
||||
func identityCacheKey(serviceAccountID valuer.UUID) string {
|
||||
return "identity::" + serviceAccountID.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -36,9 +35,6 @@ type Module interface {
|
||||
// Updates an existing service account
|
||||
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// RoleAttachSelectors returns the selectors needed to check VerbAttach permission on a role.
|
||||
RoleAttachSelectors(context.Context, valuer.UUID, valuer.UUID) ([]coretypes.Selector, error)
|
||||
|
||||
// Assign a role to the service account. this is safe to retry
|
||||
SetRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) error
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/govaluate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
@@ -969,11 +970,19 @@ func (q *querier) prepareFillZeroArgsWithStep(functions []qbtypes.Function, req
|
||||
updatedFunctions := make([]qbtypes.Function, len(functions))
|
||||
copy(updatedFunctions, functions)
|
||||
|
||||
// funcFillZero expects start/end in milliseconds. req.Start/req.End may
|
||||
// arrive in s/ms/μs/ns depending on the caller; normalize via ToNanoSecs
|
||||
// (same pattern used elsewhere in the codebase, e.g. RecommendedStepInterval)
|
||||
// then convert to ms. Without this, an ns payload makes (end-start)/step
|
||||
// 10^6× too large and OOMs the process.
|
||||
startMs := querybuilder.ToNanoSecs(req.Start) / 1_000_000
|
||||
endMs := querybuilder.ToNanoSecs(req.End) / 1_000_000
|
||||
|
||||
for i, fn := range updatedFunctions {
|
||||
if fn.Name == qbtypes.FunctionNameFillZero && len(fn.Args) == 0 {
|
||||
fn.Args = []qbtypes.FunctionArg{
|
||||
{Value: float64(req.Start)},
|
||||
{Value: float64(req.End)},
|
||||
{Value: float64(startMs)},
|
||||
{Value: float64(endMs)},
|
||||
{Value: float64(step)},
|
||||
}
|
||||
updatedFunctions[i] = fn
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
errors "github.com/SigNoz/signoz/pkg/errors/v2"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// HAVING-expression validator codes. All three are caller-fault, fix-the-input
|
||||
// errors — the user wrote an expression we cannot turn into SQL — so retry
|
||||
// is pointless until the expression itself changes.
|
||||
var (
|
||||
codeHavingStringLiteral = errors.Register("querybuilder.having.string_literal", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingInvalidReference = errors.Register("querybuilder.having.invalid_reference", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingSyntaxError = errors.Register("querybuilder.having.syntax_error", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
)
|
||||
|
||||
// havingExpressionRewriteVisitor walks the parse tree of a HavingExpression in a single
|
||||
// pass, simultaneously rewriting user-facing references to their SQL column names and
|
||||
// collecting any references that could not be resolved.
|
||||
@@ -281,10 +306,10 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
return "", errors.New(codeHavingStringLiteral,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
errors.WithDetail("Aggregator results are numeric"),
|
||||
)
|
||||
}
|
||||
|
||||
if len(v.invalid) > 0 {
|
||||
@@ -294,7 +319,10 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
validKeys = append(validKeys, k)
|
||||
}
|
||||
sort.Strings(validKeys)
|
||||
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
|
||||
opts := []errors.Option{
|
||||
errors.WithAttr("invalid_refs", v.invalid),
|
||||
errors.WithAttr("valid_refs", validKeys),
|
||||
}
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
// Only suggest for plain identifier typos, not for unresolved function
|
||||
@@ -303,15 +331,13 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// a simple string substitution produce a corrupt expression.
|
||||
isFuncCall := strings.Contains(original, inv+"(")
|
||||
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
|
||||
corrected := strings.ReplaceAll(original, inv, match)
|
||||
additional = append(additional, "Suggestion: `"+corrected+"`")
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{strings.ReplaceAll(original, inv, match)}))
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
strings.Join(v.invalid, ", "),
|
||||
).WithAdditional(additional...)
|
||||
return "", errors.New(codeHavingInvalidReference,
|
||||
fmt.Sprintf("Invalid references in `Having` expression: [%s]", strings.Join(v.invalid, ", ")),
|
||||
opts...,
|
||||
)
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -328,17 +354,20 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
if detail == "" {
|
||||
detail = "check the expression syntax"
|
||||
}
|
||||
additional := []string{detail}
|
||||
opts := []errors.Option{
|
||||
errors.WithDetail(detail),
|
||||
errors.WithAttr("syntax_errors", msgs),
|
||||
}
|
||||
// For single-error expressions, try to produce an actionable suggestion.
|
||||
if len(allSyntaxErrors) == 1 {
|
||||
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
|
||||
additional = append(additional, "Suggestion: `"+s+"`")
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{s}))
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
return "", errors.New(codeHavingSyntaxError,
|
||||
"Syntax error in `Having` expression",
|
||||
).WithAdditional(additional...)
|
||||
opts...,
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -72,7 +72,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ rawdataexport.Handler }{},
|
||||
struct{ zeus.Handler }{},
|
||||
struct{ querier.Handler }{},
|
||||
struct{ serviceaccount.Module }{},
|
||||
struct{ serviceaccount.Handler }{},
|
||||
struct{ factory.Handler }{},
|
||||
struct{ cloudintegration.Handler }{},
|
||||
|
||||
@@ -195,7 +195,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,7 +277,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RawDataExport,
|
||||
handlers.ZeusHandler,
|
||||
handlers.QuerierHandler,
|
||||
modules.ServiceAccount,
|
||||
handlers.ServiceAccountHandler,
|
||||
handlers.RegistryHandler,
|
||||
handlers.CloudIntegrationHandler,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 addServiceAccountManagedRoleTransactions struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_sa_managed_role_txn"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addServiceAccountManagedRoleTransactions{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addServiceAccountManagedRoleTransactions) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// managedRoleTuple describes a single FGA tuple to insert for a managed role.
|
||||
type managedRoleTuple struct {
|
||||
roleName string
|
||||
objectType string // "metaresources" or "metaresource"
|
||||
objectName string // "service-accounts" or "service-account"
|
||||
relation string // "create", "list", "read", "update", "delete"
|
||||
}
|
||||
|
||||
func (migration *addServiceAccountManagedRoleTransactions) Up(ctx context.Context, db *bun.DB) error {
|
||||
// All tuples that need to be created for service account FGA managed role permissions.
|
||||
tuples := []managedRoleTuple{
|
||||
{authtypes.SigNozAdminRoleName, "role", "role", "attach"},
|
||||
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "attach"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresources", "serviceaccount", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresources", "serviceaccount", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "delete"},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Fetch all orgs.
|
||||
var orgIDs []string
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var orgID string
|
||||
if err := rows.Scan(&orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
orgIDs = append(orgIDs, orgID)
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
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, "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, 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 *addServiceAccountManagedRoleTransactions) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func GetAdditionTuples(name string, orgID valuer.UUID, relation Relation, additi
|
||||
transactionTuples := NewTuples(
|
||||
resource,
|
||||
MustNewSubject(
|
||||
coretypes.NewResourceRole(),
|
||||
resource,
|
||||
name,
|
||||
orgID,
|
||||
&coretypes.VerbAssignee,
|
||||
@@ -200,7 +200,7 @@ func GetDeletionTuples(name string, orgID valuer.UUID, relation Relation, deleti
|
||||
transactionTuples := NewTuples(
|
||||
resource,
|
||||
MustNewSubject(
|
||||
coretypes.NewResourceRole(),
|
||||
resource,
|
||||
name,
|
||||
orgID,
|
||||
&coretypes.VerbAssignee,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
|
||||
type SelectorCallbackWithClaimsFn func(*http.Request, Claims) ([]coretypes.Selector, error)
|
||||
type SelectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
|
||||
|
||||
var (
|
||||
ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable")
|
||||
ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden")
|
||||
|
||||
@@ -9,6 +9,7 @@ var Resources = []Resource{
|
||||
ResourceMetaResourcesRole,
|
||||
ResourceMetaResourcesOrganization,
|
||||
ResourceMetaResourcesServiceAccount,
|
||||
ResourceMetaResourcesServiceAccount,
|
||||
ResourceMetaResourcesUser,
|
||||
ResourceMetaResourceNotificationChannel,
|
||||
ResourceMetaResourcesNotificationChannel,
|
||||
|
||||
@@ -28,8 +28,6 @@ func NewVerb(verb string) (Verb, error) {
|
||||
return VerbList, nil
|
||||
case "assignee":
|
||||
return VerbAssignee, nil
|
||||
case "attach":
|
||||
return VerbAttach, nil
|
||||
default:
|
||||
return Verb{}, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidVerb, "verb %s is invalid, valid verbs are: %s", verb, Verb{}.Enum())
|
||||
}
|
||||
@@ -43,7 +41,6 @@ func (Verb) Enum() []any {
|
||||
VerbDelete,
|
||||
VerbList,
|
||||
VerbAssignee,
|
||||
VerbAttach,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ pytest_plugins = [
|
||||
"fixtures.cloudintegrations",
|
||||
"fixtures.jsontypes",
|
||||
"fixtures.seeder",
|
||||
"fixtures.serviceaccount",
|
||||
"fixtures.role",
|
||||
]
|
||||
|
||||
|
||||
|
||||
76
tests/fixtures/role.py
vendored
76
tests/fixtures/role.py
vendored
@@ -1,76 +0,0 @@
|
||||
"""Fixtures and helpers for role tests."""
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ROLES_BASE = "/api/v1/roles"
|
||||
|
||||
|
||||
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
"""Find a role by name from the roles endpoint and return its UUID."""
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
roles = resp.json()["data"]
|
||||
role = next(r for r in roles if r["name"] == name)
|
||||
return role["id"]
|
||||
|
||||
|
||||
def create_custom_role(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
"""Create a custom role and return its ID."""
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": name},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
return resp.json()["data"]["id"]
|
||||
|
||||
|
||||
def delete_custom_role(signoz: types.SigNoz, token: str, role_id: str) -> None:
|
||||
"""Delete a custom role."""
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def patch_role_objects(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
role_id: str,
|
||||
relation: str,
|
||||
additions=None,
|
||||
deletions=None,
|
||||
) -> None:
|
||||
"""PATCH /api/v1/roles/{id}/relations/{relation}/objects."""
|
||||
body = {}
|
||||
if additions is not None:
|
||||
body["additions"] = additions
|
||||
if deletions is not None:
|
||||
body["deletions"] = deletions
|
||||
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{role_id}/relations/{relation}/objects"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"PatchObjects {relation} failed: {resp.text}"
|
||||
|
||||
|
||||
def object_group(type_name: str, kind_name: str, selectors: list[str]) -> dict:
|
||||
"""Build an ObjectGroup dict for PatchObjects."""
|
||||
return {"resource": {"type": type_name, "kind": kind_name}, "selectors": selectors}
|
||||
26
tests/fixtures/serviceaccount.py
vendored
26
tests/fixtures/serviceaccount.py
vendored
@@ -6,11 +6,24 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.role import ROLES_BASE, find_role_by_name # noqa: F401 — re-export for existing callers
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
SERVICE_ACCOUNT_BASE = "/api/v1/service_accounts"
|
||||
ROLES_BASE = "/api/v1/roles"
|
||||
|
||||
|
||||
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
"""Find a role by name from the roles endpoint and return its UUID."""
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
roles = resp.json()["data"]
|
||||
role = next(r for r in roles if r["name"] == name)
|
||||
return role["id"]
|
||||
|
||||
|
||||
def create_service_account(signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer") -> str:
|
||||
@@ -62,17 +75,6 @@ def delete_service_account(signoz: types.SigNoz, token: str, service_account_id:
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def get_first_key_id(signoz: types.SigNoz, token: str, service_account_id: str) -> str:
|
||||
"""Return the ID of the first API key for a service account."""
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/keys"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
return resp.json()["data"][0]["id"]
|
||||
|
||||
|
||||
def find_service_account_by_name(signoz: types.SigNoz, token: str, name: str) -> dict:
|
||||
"""Find a service account by name from the list endpoint."""
|
||||
list_resp = requests.get(
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
"""Tests for resource-level FGA on service account endpoints.
|
||||
|
||||
Validates that a custom role with specific SA permissions gets exactly
|
||||
the access it was granted, and that SA role assignment requires BOTH
|
||||
serviceaccount:attach AND role:attach.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import (
|
||||
create_custom_role,
|
||||
delete_custom_role,
|
||||
find_role_by_name,
|
||||
object_group,
|
||||
patch_role_objects,
|
||||
)
|
||||
from fixtures.serviceaccount import (
|
||||
SERVICE_ACCOUNT_BASE,
|
||||
create_service_account,
|
||||
find_service_account_by_name,
|
||||
get_first_key_id,
|
||||
)
|
||||
|
||||
SA_FGA_CUSTOM_ROLE_NAME = "sa-fga-readonly"
|
||||
SA_FGA_CUSTOM_USER_EMAIL = "customrole+safga@integration.test"
|
||||
SA_FGA_CUSTOM_USER_PASSWORD = "password123Z$"
|
||||
SA_FGA_TARGET_SA_NAME = "sa-fga-target"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Apply license (required for custom role CRUD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Create custom role + user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_custom_role_readonly_sa(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create the custom role.
|
||||
role_id = create_custom_role(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Grant read on serviceaccount instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on serviceaccount collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Create the custom-role user: invite as VIEWER, activate, change role.
|
||||
user_id = create_active_user(
|
||||
signoz,
|
||||
admin_token,
|
||||
email=SA_FGA_CUSTOM_USER_EMAIL,
|
||||
role="VIEWER",
|
||||
password=SA_FGA_CUSTOM_USER_PASSWORD,
|
||||
name="sa-fga-test-user",
|
||||
)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", SA_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Create a target SA (with role + key) for the custom user to operate on.
|
||||
sa_id = create_service_account(signoz, admin_token, SA_FGA_TARGET_SA_NAME, role="signoz-viewer")
|
||||
|
||||
# Create a key on the target SA.
|
||||
key_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
json={"name": "fga-key", "expiresAt": 0},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert key_resp.status_code == HTTPStatus.CREATED, key_resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Read-only access: allowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_readonly_role_allowed_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
# List SAs.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list SAs: {resp.text}"
|
||||
|
||||
# Get SA.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get SA: {resp.text}"
|
||||
|
||||
# Get SA roles.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get SA roles: {resp.text}"
|
||||
|
||||
# List SA keys.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list SA keys: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Read-only access: forbidden operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_readonly_role_forbidden_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
key_id = get_first_key_id(signoz, admin_token, sa_id)
|
||||
|
||||
# Create SA — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
json={"name": "sa-fga-should-fail"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Update SA — forbidden.
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
json={"name": "sa-fga-renamed"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"update SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Delete SA — forbidden.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"delete SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Assign role to SA — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Remove role from SA — forbidden.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Create key — forbidden (needs update).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
json={"name": "fga-key-fail", "expiresAt": 0},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create key: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Revoke key — forbidden (needs update).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys/{key_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"revoke key: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Grant write permissions, verify access opens up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_role_add_write_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Grant create on collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"create",
|
||||
additions=[
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant update on instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"update",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant delete on instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"delete",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Create SA — now allowed.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
json={"name": "sa-fga-write-test"},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, f"create SA: {resp.text}"
|
||||
new_sa_id = resp.json()["data"]["id"]
|
||||
|
||||
# Update SA — now allowed.
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}"),
|
||||
json={"name": "sa-fga-write-renamed"},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"update SA: {resp.text}"
|
||||
|
||||
# Create key — now allowed (update permission covers key create).
|
||||
key_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys"),
|
||||
json={"name": "fga-write-key", "expiresAt": 0},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert key_resp.status_code == HTTPStatus.CREATED, f"create key: {key_resp.text}"
|
||||
new_key_id = key_resp.json()["data"]["id"]
|
||||
|
||||
# Revoke key — now allowed (update permission covers key revoke).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys/{new_key_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"revoke key: {resp.text}"
|
||||
|
||||
# Delete SA — now allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete SA: {resp.text}"
|
||||
|
||||
# Role assignment still forbidden (no attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Dual-attach: SA attach only (no role attach) → forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_with_only_sa_attach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Grant attach on serviceaccount only.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Assign role — forbidden (has SA attach, missing role attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only SA attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Remove role — forbidden (CheckAll: role attach group fails).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only SA attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Dual-attach: role attach only (no SA attach) → forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_with_only_role_attach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Remove SA attach, grant role attach.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
deletions=[object_group("serviceaccount", "serviceaccount", ["*"])],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Assign role — forbidden (middleware SA attach check fails).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only role attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Remove role — forbidden (CheckAll: SA attach group fails).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only role attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Dual-attach: both SA + role attach → succeeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_with_both_permissions_succeeds(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
# Add back SA attach (role attach already present from previous test).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# The target SA currently has signoz-viewer assigned. Assign a different role.
|
||||
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
|
||||
|
||||
# Assign editor role — should succeed (both SA attach + role attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": editor_role_id},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"assign with both attach: {resp.text}"
|
||||
|
||||
# Remove the editor role — should succeed (CheckAll: both groups pass).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{editor_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove with both attach: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Revoke read/list → verify access lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_read_permissions_revokes_access(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
# Revoke read.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
deletions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Revoke list.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
deletions=[
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# List SAs — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"list SAs after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Get SA — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"get SA after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Clean up: delete custom role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_custom_role_cleanup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
user = find_user_by_email(signoz, admin_token, SA_FGA_CUSTOM_USER_EMAIL)
|
||||
|
||||
# Remove the custom role from the user first — role deletion requires no assignees.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
roles = resp.json()["data"]
|
||||
custom_entry = next((r for r in roles if r["name"] == SA_FGA_CUSTOM_ROLE_NAME), None)
|
||||
if custom_entry is not None:
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles/{custom_entry['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
|
||||
|
||||
delete_custom_role(signoz, admin_token, role_id)
|
||||
Reference in New Issue
Block a user