feat: add new role permissions for app creation and backup access, update related models and utilities

This commit is contained in:
biersoeckli
2025-03-07 15:51:44 +00:00
parent cd1da58106
commit 48575b2e45
26 changed files with 237 additions and 78 deletions

View File

@@ -0,0 +1,18 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Role" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"canCreateNewApps" BOOLEAN NOT NULL DEFAULT false,
"canAccessBackups" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Role" ("createdAt", "description", "id", "name", "updatedAt") SELECT "createdAt", "description", "id", "name", "updatedAt" FROM "Role";
DROP TABLE "Role";
ALTER TABLE "new_Role" RENAME TO "Role";
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -118,14 +118,13 @@ model Authenticator {
// *** FROM HERE CUSTOM CLASSES
model Role {
id String @id @default(uuid())
name String @unique
description String?
id String @id @default(uuid())
name String @unique
description String?
canCreateNewApps Boolean @default(false)
canAccessBackups Boolean @default(false)
// A Role can be assigned to multiple users
users User[]
// A Role defines permissions on apps via the join model below
users User[]
roleAppPermissions RoleAppPermission[]
createdAt DateTime @default(now())

View File

@@ -4,7 +4,7 @@ import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service";
import pvcService from "@/server/services/pvc.service";
import backupService from "@/server/services/standalone-services/backup.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedForBackups, simpleAction } from "@/server/utils/action-wrapper.utils";
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import { NodeResourceModel } from "@/shared/model/node-resource.model";
@@ -13,7 +13,7 @@ import { z } from "zod";
export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedForBackups();
const validatetData = z.object({
s3TargetId: z.string(),
@@ -29,7 +29,7 @@ export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
export const deleteBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedForBackups();
const validatetData = z.object({
s3TargetId: z.string(),

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedForBackups } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import backupService from "@/server/services/standalone-services/backup.service";
import BackupsTable from "./backups-table";
@@ -14,7 +14,7 @@ import {
export default async function BackupsPage() {
await getAuthUserSession();
await isAuthorizedForBackups();
const {
backupInfoModels,
backupsVolumesWithoutActualBackups

43
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client' // Error boundaries must be Client Components
import { Button } from "@/components/ui/button"
import { cn } from "@/frontend/utils/utils";
import { AlertCircle } from "lucide-react"
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html >
<body className={cn(
"min-h-screen bg-background font-sans antialiased",
inter.variable
)}>
<div className="h-screen w-fuäll flex flex-col items-center justify-center p-4 space-y-4 bg-background text-foreground">
<div className="flex flex-col items-center justify-center space-y-2 text-center max-w-md">
<div className="rounded-full bg-destructive/10 p-3">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-2xl font-bold tracking-tight">Something went wrong!</h2>
<p className="text-muted-foreground mt-4">
An unexpected error occurred. Please check if your authorized for this action and try again.
</p>
<p className="text-xs text-muted-foreground mt-6">
Digest: {error.digest}
</p>
</div>
</div>
</body>
</html>
)
}

View File

@@ -3,6 +3,7 @@
import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { RoleUtils } from "@/server/utils/role.utils";
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import { NodeResourceModel } from "@/shared/model/node-resource.model";
@@ -16,12 +17,16 @@ export const getNodeResourceUsage = async () =>
export const getVolumeMonitoringUsage = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await monitoringService.getAllAppVolumesUsage();
const session = await getAuthUserSession();
let volumesUsage = await monitoringService.getAllAppVolumesUsage();
volumesUsage = volumesUsage?.filter((volume) => RoleUtils.sessionHasReadAccessForApp(session, volume.appId));
return volumesUsage;
}) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;
export const getMonitoringForAllApps = async () =>
simpleAction(async () => {
await getAuthUserSession();
return await monitoringService.getMonitoringForAllApps();
const session = await getAuthUserSession();
let updatedNodeRessources = await monitoringService.getMonitoringForAllApps();
updatedNodeRessources = updatedNodeRessources?.filter((app) => RoleUtils.sessionHasReadAccessForApp(session, app.appId));
return updatedNodeRessources;
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;

View File

@@ -10,10 +10,11 @@ import monitoringService from "@/server/services/monitoring.service";
import AppRessourceMonitoring from "./app-monitoring";
import AppVolumeMonitoring from "./app-volumes-monitoring";
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { RoleUtils } from "@/server/utils/role.utils";
export default async function ResourceNodesInfoPage() {
await getAuthUserSession();
const session = await getAuthUserSession();
let resourcesNode: NodeResourceModel[] | undefined;
let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined;
let updatedNodeRessources: AppMonitoringUsageModel[] | undefined;
@@ -27,6 +28,10 @@ export default async function ResourceNodesInfoPage() {
// do nothing --> if an error occurs, the ResourceNodes will show a loading spinner and error message
}
// filter by role
volumesUsage = volumesUsage?.filter((volume) => RoleUtils.sessionHasReadAccessForApp(session, volume.appId));
updatedNodeRessources = updatedNodeRessources?.filter((app) => RoleUtils.sessionHasReadAccessForApp(session, app.appId));
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle

View File

@@ -2,7 +2,7 @@
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import appTemplateService from "@/server/services/app-template.service";
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model";
@@ -11,6 +11,7 @@ import dbGateService from "@/server/services/db-tool-services/dbgate.service";
import fileBrowserService from "@/server/services/file-browser-service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
import { RoleUtils } from "@/server/utils/role.utils";
const createAppSchema = z.object({
appName: z.string().min(1)
@@ -18,7 +19,10 @@ const createAppSchema = z.object({
export const createApp = async (appName: string, projectId: string, appId?: string) =>
saveFormAction({ appName }, createAppSchema, async (validatedData) => {
await getAuthUserSession();
const session = await getAuthUserSession();
if (!RoleUtils.sessionCanCreateNewApps(session)) {
throw new ServiceException("You are not allowed to create new apps.");
}
const returnData = await appService.save({
id: appId ?? undefined,
@@ -31,7 +35,10 @@ export const createApp = async (appName: string, projectId: string, appId?: stri
export const createAppFromTemplate = async (prevState: any, inputData: AppTemplateModel, projectId: string) =>
saveFormAction(inputData, appTemplateZodModel, async (validatedData) => {
await getAuthUserSession();
const session = await getAuthUserSession();
if (!RoleUtils.sessionCanCreateNewApps(session)) {
throw new ServiceException("You are not allowed to create new apps.");
}
if (validatedData.templates.some(x => x.inputSettings.some(y => !y.randomGeneratedIfEmpty && !y.value))) {
throw new ServiceException('Please fill out all required fields.');
}
@@ -41,7 +48,7 @@ export const createAppFromTemplate = async (prevState: any, inputData: AppTempla
export const deleteApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const app = await appService.getExtendedById(appId);
// First delete external services wich might be running
await dbGateService.deleteToolForAppIfExists(appId);

View File

@@ -2,8 +2,10 @@
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import projectService from "@/server/services/project.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { RoleUtils } from "@/server/utils/role.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
const createProjectSchema = z.object({
projectName: z.string().min(1),
@@ -12,7 +14,10 @@ const createProjectSchema = z.object({
export const createProject = async (projectName: string, projectId?: string) =>
saveFormAction({ projectName, projectId }, createProjectSchema, async (validatedData) => {
await getAuthUserSession();
const session = await getAuthUserSession();
if (!RoleUtils.sessionCanCreateNewApps(session)) {
throw new ServiceException("You are not allowed to create new projects.");
}
await projectService.save({
id: validatedData.projectId ?? undefined,
name: validatedData.projectName
@@ -22,7 +27,7 @@ export const createProject = async (projectName: string, projectId?: string) =>
export const deleteProject = async (projectId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await projectService.deleteById(projectId);
return new SuccessActionResult(undefined, "Project deleted successfully.");
});

View File

@@ -10,12 +10,10 @@ import { Edit2, Eye, MoreHorizontal, Trash } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils";
import { Project } from "@prisma/client";
import { deleteProject } from "./actions";
import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states";
import { useEffect } from "react";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { EditProjectDialog } from "./edit-project-dialog";
export default function ProjectsTable({ data }: { data: Project[] }) {
const { openConfirmDialog: openDialog } = useConfirmDialog();

View File

@@ -1,12 +1,12 @@
'use server'
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import clusterService from "@/server/services/node.service";
export const setNodeStatus = async (nodeName: string, schedulable: boolean) =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await clusterService.setNodeStatus(nodeName, schedulable);
return new SuccessActionResult(undefined, 'Successfully updated node status.');
});

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import clusterService from "@/server/services/node.service";
import NodeInfo from "./nodeInfo";
@@ -11,7 +11,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function ClusterInfoPage() {
const session = await getAuthUserSession();
const session = await getAdminUserSession();
const nodeInfo = await clusterService.getNodeInfo();
const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN);
return (

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import paramService, { ParamService } from "@/server/services/param.service";
import podService from "@/server/services/pod.service";
@@ -13,7 +13,7 @@ import quickStackService from "@/server/services/qs.service";
export default async function MaintenancePage() {
await getAuthUserSession();
await getAdminUserSession();
const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME);
const qsPodInfo = qsPodInfos.find(p => !!p);

View File

@@ -1,7 +1,7 @@
'use server'
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { S3TargetEditModel, s3TargetEditZodModel } from "@/shared/model/s3-target-edit.model";
import s3TargetService from "@/server/services/s3-target.service";
import s3Service from "@/server/services/aws-s3.service";
@@ -10,7 +10,7 @@ import { ServiceException } from "@/shared/model/service.exception.model";
export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel) =>
saveFormAction(inputData, s3TargetEditZodModel, async (validatedData) => {
await getAuthUserSession();
await getAdminUserSession();
const url = new URL(validatedData.endpoint.includes('://') ? validatedData.endpoint : `https://${validatedData.endpoint}`);
validatedData.endpoint = url.hostname;
@@ -27,7 +27,7 @@ export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel)
export const deleteS3Target = async (s3TargetId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await s3TargetService.deleteById(s3TargetId);
return new SuccessActionResult(undefined, 'Successfully deleted S3 Target');
});

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import s3TargetService from "@/server/services/s3-target.service";
import S3TargetsTable from "./s3-targets-table";
@@ -10,7 +10,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function S3TargetsPage() {
await getAuthUserSession();
await getAdminUserSession();
const data = await s3TargetService.getAll();
return (
<div className="flex-1 space-y-4 pt-6">

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import paramService, { ParamService } from "@/server/services/param.service";
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/shared/model/qs-settings.model";
import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/shared/model/qs-letsencrypt-settings.model";
@@ -20,7 +20,7 @@ import appLogsService from "@/server/services/standalone-services/app-logs.servi
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await getAdminUserSession();
const url = new URL(validatedData.serverUrl.includes('://') ? validatedData.serverUrl : `https://${validatedData.serverUrl}`);
@@ -41,7 +41,7 @@ export const updateIngressSettings = async (prevState: any, inputData: QsIngress
export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPublicIpv4SettingsModel) =>
saveFormAction(inputData, qsPublicIpv4SettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await getAdminUserSession();
await paramService.save({
name: ParamService.PUBLIC_IPV4_ADDRESS,
@@ -52,7 +52,7 @@ export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPubl
export const updatePublicIpv4SettingsAutomatically = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
const publicIpv4 = await ipAddressFinderAdapter.getPublicIpOfServer();
await paramService.save({
@@ -63,7 +63,7 @@ export const updatePublicIpv4SettingsAutomatically = async () =>
export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) =>
saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await getAdminUserSession();
await paramService.save({
name: ParamService.LETS_ENCRYPT_MAIL,
@@ -75,7 +75,7 @@ export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLet
export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, string | undefined>> = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
return await paramService.getString(ParamService.QS_SERVER_HOSTNAME);
});
@@ -83,21 +83,21 @@ export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, st
export const cleanupOldTmpFiles = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await maintenanceService.deleteAllTempFiles();
return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.');
});
export const cleanupOldBuildJobs = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await buildService.deleteAllFailedOrSuccededBuilds();
return new SuccessActionResult(undefined, 'Successfully cleaned up old build jobs.');
});
export const updateQuickstack = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
await quickStackService.updateQuickStack(useCaranyChannel);
return new SuccessActionResult(undefined, 'QuickStack will be updated, refresh the page in a few seconds.');
@@ -105,7 +105,7 @@ export const updateQuickstack = async () =>
export const updateRegistry = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
const registryLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION);
await registryService.deployRegistry(registryLocation!, true);
return new SuccessActionResult(undefined, 'Registry will be updated, this might take a few seconds.');
@@ -113,35 +113,35 @@ export const updateRegistry = async () =>
export const updateTraefikMeCertificates = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.');
});
export const deleteAllFailedAndSuccededPods = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await standalonePodService.deleteAllFailedAndSuccededPods();
return new SuccessActionResult(undefined, 'Successfully deleted all failed and succeeded pods.');
});
export const purgeRegistryImages = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
const deletedSize = await registryService.purgeRegistryImages();
return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`);
});
export const deleteOldAppLogs = async () =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await appLogsService.deleteOldAppLogs();
return new SuccessActionResult(undefined, `Successfully deletes old app logs.`);
});
export const setCanaryChannel = async (useCanaryChannel: boolean) =>
simpleAction(async () => {
await getAuthUserSession();
await getAdminUserSession();
await paramService.save({
name: ParamService.USE_CANARY_CHANNEL,
value: !!useCanaryChannel ? 'true' : 'false'
@@ -151,7 +151,7 @@ export const setCanaryChannel = async (useCanaryChannel: boolean) =>
export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) =>
saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await getAdminUserSession();
await registryService.deployRegistry(validatedData.registryStorageLocation, true);

View File

@@ -1,6 +1,6 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import paramService, { ParamService } from "@/server/services/param.service";
import QuickStackIngressSettings from "./qs-ingress-settings";
@@ -13,7 +13,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function ProjectPage() {
const session = await getAuthUserSession();
const session = await getAdminUserSession();
const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, '');
const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false);
const letsEncryptMail = await paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email);

View File

@@ -47,7 +47,7 @@ export default function RoleEditOverlay({ children, role, apps }: {
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
payload: RoleEditModel) =>
saveRole(state, {
saveRole(state, {
...payload,
id: role?.id,
roleAppPermissions: appPermissions.flatMap(perm => {
@@ -154,6 +154,38 @@ export default function RoleEditOverlay({ children, role, apps }: {
)}
/>
<FormField
control={form.control}
name="canCreateNewApps"
render={({ field }) => (
<FormItem className="flex gap-4">
<FormLabel className="pt-2">Can create new apps</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessBackups"
render={({ field }) => (
<FormItem className="flex gap-4">
<FormLabel className="pt-2">Can access backups</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="pt-3">
<h3 className="text-sm font-medium mb-2">App Permissions</h3>
<Table>

View File

@@ -31,6 +31,7 @@ import { usePathname } from "next/navigation"
import { useRouter } from "next/router"
import { useEffect, useState } from "react"
import QuickStackLogo from "@/components/custom/quickstack-logo"
import { RoleUtils } from "@/server/utils/role.utils"
const settingsMenu = [
@@ -43,26 +44,28 @@ const settingsMenu = [
title: "Users & Roles",
url: "/settings/users",
icon: User2,
adminOnly: true,
},
{
title: "S3 Targets",
url: "/settings/s3-targets",
icon: Settings,
adminOnly: true,
},
{
title: "QuickStack Settings",
url: "/settings/server",
icon: Settings,
adminOnly: true,
},
{
title: "Cluster",
url: "/settings/cluster",
icon: Server,
adminOnly: true,
},
{
title: "Maintenance",
url: "/settings/maintenance",
icon: Settings,
adminOnly: true,
},
]
@@ -118,7 +121,7 @@ export function SidebarCient({
<SidebarMenuButton size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-qs-500 text-sidebar-primary-foreground">
<QuickStackLogo className="size-5" color="light-all" />
<QuickStackLogo className="size-5" color="light-all" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight my-4">
<span className="truncate font-semibold">QuickStack</span>
@@ -231,12 +234,12 @@ export function SidebarCient({
</SidebarGroup>
<SidebarGroup>
{RoleUtils.sessionHasAccessToBackups(session) && <SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={{
children: 'Monitoring',
children: 'Backups',
hidden: open,
}}
isActive={path.startsWith('/backups')}>
@@ -248,7 +251,7 @@ export function SidebarCient({
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarGroup>}
<SidebarGroup>
@@ -265,16 +268,16 @@ export function SidebarCient({
</Link>
</SidebarMenuButton>
<SidebarMenuSub>
{settingsMenu.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuSubItem>
))}
{(RoleUtils.isAdmin(session) ? settingsMenu :
settingsMenu.filter(x => !x.adminOnly)).map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>

View File

@@ -1,6 +1,7 @@
import projectService from "@/server/services/project.service"
import { getUserSession } from "@/server/utils/action-wrapper.utils"
import { SidebarCient } from "./sidebar-client"
import { RoleUtils } from "@/server/utils/role.utils";
export async function AppSidebar() {
@@ -12,5 +13,11 @@ export async function AppSidebar() {
const projects = await projectService.getAllProjects();
return <SidebarCient projects={projects} session={session} />
const relevantProjectsForUser = projects.filter((project) =>
project.apps.some((app) => RoleUtils.sessionHasReadAccessForApp(session, app.id)));
for (const project of relevantProjectsForUser) {
project.apps = project.apps.filter((app) => RoleUtils.sessionHasReadAccessForApp(session, app.id));
}
return <SidebarCient projects={relevantProjectsForUser} session={session} />
}

View File

@@ -15,6 +15,8 @@ export class RoleService {
select: {
name: true,
id: true,
canAccessBackups: true,
canCreateNewApps: true,
roleAppPermissions: {
select: {
appId: true,
@@ -47,6 +49,8 @@ export class RoleService {
},
data: {
name: item.name,
canAccessBackups: item.canAccessBackups,
canCreateNewApps: item.canCreateNewApps,
roleAppPermissions: {
deleteMany: {},
createMany: {

View File

@@ -7,7 +7,7 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo
import { FormValidationException } from "@/shared/model/form-validation-exception.model";
import { authOptions } from "@/server/utils/auth-options";
import { NextResponse } from "next/server";
import roleService, { adminRoleName } from "../services/role.service";
import roleService from "../services/role.service";
import { Role } from "@prisma/client";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
import { RoleUtils } from "./role.utils";
@@ -28,6 +28,8 @@ export async function getUserSession(): Promise<UserSession | null> {
let role: {
id: string;
name: string;
canAccessBackups: boolean,
canCreateNewApps: boolean,
roleAppPermissions: {
appId: string;
permission: string;
@@ -40,6 +42,8 @@ export async function getUserSession(): Promise<UserSession | null> {
email: session?.user?.email as string,
roleId: role?.id,
roleName: role?.name,
canAccessBackups: role?.canAccessBackups,
canCreateNewApps: role?.canCreateNewApps,
permissions: role?.roleAppPermissions as roleAppPermissions[],
};
}
@@ -62,6 +66,15 @@ export async function getAdminUserSession(): Promise<UserSession> {
return session;
}
export async function isAuthorizedForBackups() {
const session = await getAuthUserSession();
if (!RoleUtils.sessionHasAccessToBackups(session)) {
console.error('User is not authorized for backups.');
throw new ServiceException('User is not authorized for this action.');
}
return session;
}
export async function isAuthorizedForRoleId(roleId: string) {
const session = await getAuthUserSession();
if (!RoleUtils.isAdmin(session) && session.roleId !== roleId) {
@@ -89,7 +102,7 @@ export async function isAuthorizedReadForApp(appId: string) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');
}
const roleHasReadAccessForApp = RoleUtils.sessionHasReadAccessForApp(session, appId);
const roleHasReadAccessForApp = RoleUtils.sessionHasReadAccessForApp(session, appId);
if (!roleHasReadAccessForApp) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');
@@ -106,7 +119,7 @@ export async function isAuthorizedWriteForApp(appId: string) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');
}
const roleHasReadAccessForApp = RoleUtils.sessionHasWriteAccessForApp(session, appId);
const roleHasReadAccessForApp = RoleUtils.sessionHasWriteAccessForApp(session, appId);
if (!roleHasReadAccessForApp) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');

View File

@@ -6,6 +6,20 @@ export class RoleUtils {
return (session.permissions?.find(app => app.appId === appId)?.permission ?? null) as RolePermissionEnum | null;
}
static sessionHasAccessToBackups(session: UserSession) {
if (this.isAdmin(session)) {
return true;
}
return !!session.canAccessBackups;
}
static sessionCanCreateNewApps(session: UserSession) {
if (this.isAdmin(session)) {
return true;
}
return !!session.canCreateNewApps;
}
static sessionIsReadOnlyForApp(session: UserSession, appId: string) {
if (this.isAdmin(session)) {
return false;

View File

@@ -6,6 +6,8 @@ export const RoleModel = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullish(),
canCreateNewApps: z.boolean(),
canAccessBackups: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@@ -4,6 +4,8 @@ import { z } from "zod";
export const roleEditZodModel = z.object({
id: z.string().trim().optional(),
name: z.string().trim().min(1),
canCreateNewApps: z.boolean().optional().default(false),
canAccessBackups: z.boolean().optional().default(false),
roleAppPermissions: z.array(z.object({
appId: z.string(),
permission: z.string(),

View File

@@ -6,6 +6,8 @@ export interface UserSession {
email: string;
roleName?: string;
roleId?: string;
canAccessBackups?: boolean;
canCreateNewApps?: boolean;
permissions?: {
appId: string,
permission: RolePermissionEnum