Add per-server permissions

This commit is contained in:
jeffvli
2022-11-09 01:52:08 -08:00
parent 73e6002cc7
commit 581ef32845
13 changed files with 371 additions and 139 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ const getUserDetail = async (
req: TypedRequest<typeof validation.users.detail>, req: TypedRequest<typeof validation.users.detail>,
res: Response res: Response
) => { ) => {
const { id } = req.params; const { userId } = req.params;
const user = await service.users.findById(req.authUser, { id }); const user = await service.users.findById(req.authUser, { id: userId });
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] }); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success)); return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
@@ -0,0 +1,40 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerAdmin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (permission !== ServerPermissionType.ADMIN) {
return res.status(403).json({
error: {
message: 'This action requires "Admin" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -0,0 +1,43 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerEditor = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (
permission !== ServerPermissionType.EDITOR &&
permission !== ServerPermissionType.ADMIN
) {
return res.status(403).json({
error: {
message: 'This action requires "Editor" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -0,0 +1,40 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerViewer = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (permission === undefined) {
return res.status(403).json({
error: {
message: 'This action requires "Viewer" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -0,0 +1,20 @@
import { NextFunction, Request, Response } from 'express';
export const authenticateSuperAdmin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.authUser.isSuperAdmin) {
return res.status(403).json({
error: {
message: 'This action requires an administrator account.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
+4
View File
@@ -1,3 +1,7 @@
export * from './error-handler'; export * from './error-handler';
export * from './authenticate'; export * from './authenticate';
export * from './authenticate-admin'; export * from './authenticate-admin';
export * from './authenticate-super-admin';
export * from './authenticate-server-admin';
export * from './authenticate-server-editor';
export * from './authenticate-server-viewer';
+14 -7
View File
@@ -1,6 +1,9 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '@controllers/index'; import { controller } from '@controllers/index';
import { authenticateAdmin } from '@middleware/authenticate-admin'; import { authenticateAdmin } from '@middleware/authenticate-admin';
import { authenticateServerAdmin } from '@middleware/authenticate-server-admin';
import { authenticateServerEditor } from '@middleware/authenticate-server-editor';
import { authenticateServerViewer } from '@middleware/authenticate-server-viewer';
import { service } from '@services/index'; import { service } from '@services/index';
import { validateRequest, validation } from '@validations/index'; import { validateRequest, validation } from '@validations/index';
@@ -25,7 +28,7 @@ router
controller.servers.getServerDetail controller.servers.getServerDetail
) )
.patch( .patch(
authenticateAdmin, authenticateServerAdmin,
validateRequest(validation.servers.update), validateRequest(validation.servers.update),
controller.servers.updateServer controller.servers.updateServer
) )
@@ -38,7 +41,7 @@ router
router router
.route('/:serverId/refresh') .route('/:serverId/refresh')
.get( .get(
authenticateAdmin, authenticateServerEditor,
validateRequest(validation.servers.refresh), validateRequest(validation.servers.refresh),
controller.servers.refreshServer controller.servers.refreshServer
); );
@@ -46,23 +49,23 @@ router
router router
.route('/:serverId/scan') .route('/:serverId/scan')
.post( .post(
authenticateServerAdmin,
validateRequest(validation.servers.scan), validateRequest(validation.servers.scan),
authenticateAdmin,
controller.servers.quickScanServer controller.servers.quickScanServer
); );
router router
.route('/:serverId/full-scan') .route('/:serverId/full-scan')
.post( .post(
authenticateServerAdmin,
validateRequest(validation.servers.scan), validateRequest(validation.servers.scan),
authenticateAdmin,
controller.servers.fullScanServer controller.servers.fullScanServer
); );
router router
.route('/:serverId/url') .route('/:serverId/url')
.post( .post(
authenticateAdmin, authenticateServerEditor,
validateRequest(validation.servers.createUrl), validateRequest(validation.servers.createUrl),
controller.servers.createServerUrl controller.servers.createServerUrl
); );
@@ -75,7 +78,7 @@ router.param('urlId', async (_req, _res, next, urlId) => {
router router
.route('/:serverId/url/:urlId') .route('/:serverId/url/:urlId')
.delete( .delete(
authenticateAdmin, authenticateServerEditor,
validateRequest(validation.servers.deleteUrl), validateRequest(validation.servers.deleteUrl),
controller.servers.deleteServerUrl controller.servers.deleteServerUrl
); );
@@ -83,6 +86,7 @@ router
router router
.route('/:serverId/url/:urlId/enable') .route('/:serverId/url/:urlId/enable')
.post( .post(
authenticateServerViewer,
validateRequest(validation.servers.enableUrl), validateRequest(validation.servers.enableUrl),
controller.servers.enableServerUrl controller.servers.enableServerUrl
); );
@@ -90,6 +94,7 @@ router
router router
.route('/:serverId/url/:urlId/disable') .route('/:serverId/url/:urlId/disable')
.post( .post(
authenticateServerViewer,
validateRequest(validation.servers.disableUrl), validateRequest(validation.servers.disableUrl),
controller.servers.disableServerUrl controller.servers.disableServerUrl
); );
@@ -102,7 +107,7 @@ router.param('folderId', async (_req, _res, next, folderId) => {
router router
.route('/:serverId/folder/:folderId') .route('/:serverId/folder/:folderId')
.delete( .delete(
authenticateAdmin, authenticateServerAdmin,
validateRequest(validation.servers.deleteFolder), validateRequest(validation.servers.deleteFolder),
controller.servers.deleteServerFolder controller.servers.deleteServerFolder
); );
@@ -110,6 +115,7 @@ router
router router
.route('/:serverId/folder/:folderId/enable') .route('/:serverId/folder/:folderId/enable')
.post( .post(
authenticateServerAdmin,
validateRequest(validation.servers.enableFolder), validateRequest(validation.servers.enableFolder),
controller.servers.enableServerFolder controller.servers.enableServerFolder
); );
@@ -117,6 +123,7 @@ router
router router
.route('/:serverId/folder/:folderId/disable') .route('/:serverId/folder/:folderId/disable')
.post( .post(
authenticateServerAdmin,
validateRequest(validation.servers.disableFolder), validateRequest(validation.servers.disableFolder),
controller.servers.disableServerFolder controller.servers.disableServerFolder
); );
+2 -2
View File
@@ -11,7 +11,7 @@ const findById = async (user: AuthUser, options: { id: string }) => {
} }
const uniqueUser = await prisma.user.findUnique({ const uniqueUser = await prisma.user.findUnique({
include: { serverFolderPermissions: true }, include: { serverFolderPermissions: true, serverPermissions: true },
where: { id }, where: { id },
}); });
@@ -61,7 +61,7 @@ const createUser = async (
const createdUser = await prisma.user.create({ const createdUser = await prisma.user.create({
data: { data: {
deviceId: `${username}_${randomString(10)}`, deviceId: `${username}_${randomString(10)}`,
enabled: false, enabled: true,
isAdmin, isAdmin,
password: hashedPassword, password: hashedPassword,
username, username,
@@ -19,6 +19,7 @@ export const useLogin = (
const props = { const props = {
accessToken: res.data.accessToken, accessToken: res.data.accessToken,
permissions: { permissions: {
id: res.data.id,
isAdmin: res.data.isAdmin, isAdmin: res.data.isAdmin,
isSuperAdmin: res.data.isSuperAdmin, isSuperAdmin: res.data.isSuperAdmin,
username: res.data.username, username: res.data.username,
@@ -18,7 +18,7 @@ import { useEnableServerUrl } from '@/renderer/features/servers/mutations/use-en
import { useFullScan } from '@/renderer/features/servers/mutations/use-full-scan'; import { useFullScan } from '@/renderer/features/servers/mutations/use-full-scan';
import { useQuickScan } from '@/renderer/features/servers/mutations/use-quick-scan'; import { useQuickScan } from '@/renderer/features/servers/mutations/use-quick-scan';
import { useUpdateServer } from '@/renderer/features/servers/mutations/use-update-server'; import { useUpdateServer } from '@/renderer/features/servers/mutations/use-update-server';
import { usePermissions } from '@/renderer/features/shared'; import { ServerPermission, usePermissions } from '@/renderer/features/shared';
import { useTaskList } from '@/renderer/features/tasks'; import { useTaskList } from '@/renderer/features/tasks';
import { useAuthStore } from '@/renderer/store'; import { useAuthStore } from '@/renderer/store';
import { Font } from '@/renderer/styles'; import { Font } from '@/renderer/styles';
@@ -34,6 +34,10 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
const [addCredential, addCredentialHandlers] = useDisclosure(false); const [addCredential, addCredentialHandlers] = useDisclosure(false);
const permissions = usePermissions(); const permissions = usePermissions();
const serverPermission = permissions[
server.id as keyof typeof permissions
] as ServerPermission;
const updateServer = useUpdateServer(); const updateServer = useUpdateServer();
const enableServerUrl = useEnableServerUrl(); const enableServerUrl = useEnableServerUrl();
const disableServerUrl = useDisableServerUrl(); const disableServerUrl = useDisableServerUrl();
@@ -148,23 +152,27 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
<Group position="apart"> <Group position="apart">
<Text font={Font.EPILOGUE}>Server details</Text> <Text font={Font.EPILOGUE}>Server details</Text>
<Group spacing="md"> <Group spacing="md">
<Button {serverPermission >= ServerPermission.ADMIN && (
compact <Button
disabled={isRunningTask} compact
loading={fullScan.isLoading} disabled={isRunningTask}
variant="subtle" loading={fullScan.isLoading}
onClick={handleFullScan} variant="subtle"
> onClick={handleFullScan}
Full scan >
</Button> Full scan
<Button </Button>
compact )}
disabled={true || isRunningTask} {serverPermission >= ServerPermission.EDITOR && (
variant="subtle" <Button
onClick={handleQuickScan} compact
> disabled={true || isRunningTask}
Quick scan variant="subtle"
</Button> onClick={handleQuickScan}
>
Quick scan
</Button>
)}
</Group> </Group>
</Group> </Group>
} }
@@ -179,50 +187,58 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
<Group> <Group>
<Stack> <Stack>
<Text>URL</Text> <Text>URL</Text>
<Text>Username</Text> {serverPermission >= ServerPermission.EDITOR && (
<Text>Username</Text>
)}
</Stack> </Stack>
<Stack> <Stack>
<Text size="sm">{server.url}</Text> <Text size="sm">{server.url}</Text>
<Text size="sm">{server.username}</Text> {serverPermission >= ServerPermission.EDITOR && (
<Text size="sm">{server.username}</Text>
)}
</Stack> </Stack>
</Group> </Group>
<Group> {serverPermission >= ServerPermission.ADMIN && (
<Button <Group>
disabled={!permissions.editServer} <Button
tooltip={{ label: 'Edit server details' }} tooltip={{ label: 'Edit server details' }}
variant="default" variant="default"
onClick={() => editHandlers.toggle()} onClick={() => editHandlers.toggle()}
> >
<RiEdit2Fill color="white" /> <RiEdit2Fill color="white" />
</Button> </Button>
</Group> </Group>
)}
</Group> </Group>
)} )}
</ServerSection> </ServerSection>
<ServerSection title="Music Folders"> <ServerSection title="Music Folders">
<Stack> <Stack>
{server.serverFolders?.map((folder) => ( {server.serverFolders?.map((folder) => (
<Group key={folder.id} position="apart"> <Group key={folder.id} position="apart">
<Text size="sm">{folder.name}</Text>
<Group> <Group>
<Button <>
compact <Switch
disabled={true || !permissions.deleteServerFolder} checked={folder.enabled}
variant="subtle" disabled={serverPermission < ServerPermission.ADMIN}
> onChange={(e) =>
<RiDeleteBin2Line color="var(--danger-color)" /> handleToggleFolder(folder.id, !e.currentTarget.checked)
</Button> }
<Text size="sm">{folder.name}</Text> />
{serverPermission >= ServerPermission.ADMIN && (
<Button compact variant="subtle">
<RiDeleteBin2Line color="var(--danger-color)" />
</Button>
)}
</>
</Group> </Group>
<Switch
checked={folder.enabled}
onChange={(e) =>
handleToggleFolder(folder.id, !e.currentTarget.checked)
}
/>
</Group> </Group>
))} ))}
</Stack> </Stack>
</ServerSection> </ServerSection>
<ServerSection title="Credentials"> <ServerSection title="Credentials">
{addCredential ? ( {addCredential ? (
<AddServerCredentialForm <AddServerCredentialForm
@@ -234,32 +250,30 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
<Stack> <Stack>
{serverCredentials?.map((credential) => ( {serverCredentials?.map((credential) => (
<Group key={credential.id} position="apart"> <Group key={credential.id} position="apart">
<Text size="sm">{credential.username}</Text>
<Group> <Group>
<Switch
checked={credential.enabled}
onChange={(e) =>
handleToggleCredential(
credential.id,
!e.currentTarget.checked
)
}
/>
<Button <Button
compact compact
disabled={!permissions.deleteServerCredential}
variant="subtle" variant="subtle"
onClick={() => handleDeleteCredential(credential.id)} onClick={() => handleDeleteCredential(credential.id)}
> >
<RiDeleteBin2Line color="var(--danger-color)" /> <RiDeleteBin2Line color="var(--danger-color)" />
</Button> </Button>
<Text size="sm">{credential.username}</Text>
</Group> </Group>
<Switch
checked={credential.enabled}
onChange={(e) =>
handleToggleCredential(
credential.id,
!e.currentTarget.checked
)
}
/>
</Group> </Group>
))} ))}
</Stack> </Stack>
<Button <Button
compact compact
disabled={!permissions.createServerCredential}
mt={10} mt={10}
variant="subtle" variant="subtle"
onClick={() => addCredentialHandlers.open()} onClick={() => addCredentialHandlers.open()}
@@ -269,6 +283,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
</> </>
)} )}
</ServerSection> </ServerSection>
<ServerSection title="URLs"> <ServerSection title="URLs">
{addUrl ? ( {addUrl ? (
<AddServerUrlForm <AddServerUrlForm
@@ -280,60 +295,75 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
<Stack> <Stack>
{server.serverUrls?.map((serverUrl) => ( {server.serverUrls?.map((serverUrl) => (
<Group key={serverUrl.id} position="apart"> <Group key={serverUrl.id} position="apart">
<Text size="sm">{serverUrl.url}</Text>
<Group> <Group>
<Button <Switch
compact checked={serverUrl.enabled}
disabled={!permissions.deleteServerUrl} onChange={(e) =>
variant="subtle" handleToggleUrl(
onClick={() => handleDeleteUrl(serverUrl.id)} serverUrl.id,
> !e.currentTarget.checked
<RiDeleteBin2Line color="var(--danger-color)" /> )
</Button> }
<Text size="sm">{serverUrl.url}</Text> />
{serverPermission >= ServerPermission.EDITOR && (
<Button
compact
variant="subtle"
onClick={() => handleDeleteUrl(serverUrl.id)}
>
<RiDeleteBin2Line color="var(--danger-color)" />
</Button>
)}
</Group> </Group>
<Switch
checked={serverUrl.enabled}
onChange={(e) =>
handleToggleUrl(serverUrl.id, !e.currentTarget.checked)
}
/>
</Group> </Group>
))} ))}
</Stack> </Stack>
<Button {serverPermission >= ServerPermission.EDITOR && (
compact <Button
disabled={!permissions.createServerUrl} compact
mt={10} mt={10}
variant="subtle" variant="subtle"
onClick={() => addUrlHandlers.open()} onClick={() => addUrlHandlers.open()}
> >
Add url Add url
</Button> </Button>
)}
</> </>
)} )}
</ServerSection> </ServerSection>
<ServerSection title="Danger zone"> {serverPermission >= ServerPermission.ADMIN && (
<Group position="apart"> <ServerSection title="Danger zone">
<Text size="sm">Require user credentials</Text> <Group position="apart">
<Switch <Text size="sm">Require user credentials</Text>
checked={server.noCredential} <Switch
disabled={!permissions.isAdmin} checked={server.noCredential}
onChange={(e) => onChange={(e) =>
toggleRequiredCredential(e.currentTarget.checked) toggleRequiredCredential(e.currentTarget.checked)
} }
/> />
</Group> </Group>
<Divider my="xl" /> {permissions.isSuperAdmin && (
<Button <>
compact <Divider my="xl" />
disabled={!permissions.deleteServer} <Button
leftIcon={<RiDeleteBin2Line color="var(--danger-color)" />} compact
variant="default" leftIcon={<RiDeleteBin2Line />}
> size="xs"
Delete server sx={{
</Button> '&:hover': {
</ServerSection> background: 'var(--danger-color)',
},
background: 'var(--danger-color)',
}}
>
Delete server
</Button>
</>
)}
</ServerSection>
)}
</Stack> </Stack>
</> </>
); );
@@ -1,27 +1,63 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/renderer/api/query-keys';
import { ServerListResponse } from '@/renderer/api/servers.api';
import { ServerPermissionType } from '@/renderer/api/types';
import { UserDetailResponse } from '@/renderer/api/users.api';
import { useAuthStore } from '@/renderer/store'; import { useAuthStore } from '@/renderer/store';
export enum ServerPermission {
VIEWER = 0,
EDITOR = 1,
ADMIN = 2,
}
const SERVER_PERMISSION_MAP = {
[ServerPermissionType.VIEWER]: 0,
[ServerPermissionType.EDITOR]: 1,
[ServerPermissionType.ADMIN]: 2,
};
export const usePermissions = () => { export const usePermissions = () => {
const queryClient = useQueryClient();
const userId = useAuthStore((state) => state.permissions.id);
const permissions = useAuthStore((state) => state.permissions); const permissions = useAuthStore((state) => state.permissions);
const permissionSet = useMemo(() => { const user = queryClient.getQueryData<UserDetailResponse>(
const set = { queryKeys.users.detail(userId)
createServer: permissions.isAdmin, );
createServerCredential: true,
createServerUrl: permissions.isAdmin,
deleteServer: permissions.isAdmin,
deleteServerCredential: true,
deleteServerFolder: permissions.isAdmin,
deleteServerUrl: permissions.isAdmin,
editServer: permissions.isAdmin,
editServerFolder: permissions.isAdmin,
isAdmin: permissions.isAdmin,
isSuperAdmin: permissions.isSuperAdmin,
manageUsers: permissions.isAdmin,
};
return set; const servers = queryClient.getQueryData<ServerListResponse>(
}, [permissions]); queryKeys.servers.list()
);
const permissionSet: { [key: string]: any } = useMemo(() => {
const serverPermissions: { [key: string]: ServerPermission } = {};
servers?.data?.forEach((server) => {
const permission = user?.data?.serverPermissions?.find(
(p) => p.serverId === server.id
)?.type;
serverPermissions[server.id] =
permissions.isAdmin || permissions.isSuperAdmin
? ServerPermission.ADMIN
: permission
? SERVER_PERMISSION_MAP[permission]
: -1;
});
return {
isAdmin: permissions.isAdmin || permissions.isSuperAdmin,
isSuperAdmin: permissions.isSuperAdmin,
...serverPermissions,
};
}, [
permissions.isAdmin,
permissions.isSuperAdmin,
servers?.data,
user?.data?.serverPermissions,
]);
return permissionSet; return permissionSet;
}; };
@@ -1,16 +1,20 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '@/renderer/api'; import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys'; import { queryKeys } from '@/renderer/api/query-keys';
import { UserDetailResponse } from '@/renderer/api/users.api';
import { QueryOptions } from '@/renderer/lib/react-query';
export const useUserDetail = (options: { userId: string }) => { export const useUserDetail = (
const { data, error, isLoading } = useQuery({ q: { userId: string },
queryFn: () => api.users.getUserDetail({ userId: options.userId }), options?: QueryOptions<UserDetailResponse>
queryKey: queryKeys.users.detail(options.userId), ) => {
const query = useQuery({
cacheTime: Infinity,
queryFn: () => api.users.getUserDetail({ userId: q.userId }),
queryKey: queryKeys.users.detail(q.userId),
staleTime: 1000 * 60 * 60 * 24,
...options,
}); });
return { return query;
data,
error,
isLoading,
};
}; };
+8 -1
View File
@@ -8,6 +8,7 @@ export interface AuthState {
accessToken: string; accessToken: string;
currentServer: Server | null; currentServer: Server | null;
permissions: { permissions: {
id: string;
isAdmin: boolean; isAdmin: boolean;
isSuperAdmin: boolean; isSuperAdmin: boolean;
username: string; username: string;
@@ -91,11 +92,17 @@ export const useAuthStore = create<AuthSlice>()(
logout: () => { logout: () => {
return set({ return set({
accessToken: undefined, accessToken: undefined,
permissions: { isAdmin: false, isSuperAdmin: false, username: '' }, permissions: {
id: '',
isAdmin: false,
isSuperAdmin: false,
username: '',
},
refreshToken: undefined, refreshToken: undefined,
}); });
}, },
permissions: { permissions: {
id: '',
isAdmin: false, isAdmin: false,
isSuperAdmin: false, isSuperAdmin: false,
username: '', username: '',