Compare commits

...

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
95a680cf9f feat(authz): migrate authorization to authz instead of user.role 2026-03-03 15:54:04 -03:00
14 changed files with 402 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ export default {
name: 'dashboards',
type: 'metaresources',
},
{
name: 'role',
type: 'role',
},
],
relations: {
create: ['metaresources'],
@@ -18,6 +22,7 @@ export default {
list: ['metaresources'],
read: ['user', 'role', 'organization', 'metaresource'],
update: ['user', 'role', 'organization', 'metaresource'],
assignee: ['role'],
},
},
} as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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