feat: enhance role-based access control by adding permissions to user session and implementing role utility functions

This commit is contained in:
biersoeckli
2025-03-07 10:00:45 +00:00
parent 79caa1ecb3
commit 88e955d4b0
14 changed files with 232 additions and 84 deletions

View File

@@ -8,6 +8,7 @@ import appService from "@/server/services/app.service";
import PageTitle from "@/components/custom/page-title";
import ProjectBreadcrumbs from "./project-breadcrumbs";
import CreateProjectActions from "./create-project-actions";
import { RoleUtils } from "@/server/utils/role.utils";
export default async function AppsPage({
searchParams,
@@ -16,7 +17,7 @@ export default async function AppsPage({
searchParams?: { [key: string]: string | undefined };
params: { projectId: string }
}) {
await getAuthUserSession();
const session = await getAuthUserSession();
const projectId = params?.projectId;
if (!projectId) {
@@ -24,6 +25,9 @@ export default async function AppsPage({
}
const project = await projectService.getById(projectId);
const data = await appService.getAllAppsByProjectID(projectId);
const relevantApps = data.filter((app) =>
RoleUtils.sessionHasReadAccessForApp(session, app.id));
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
@@ -31,7 +35,7 @@ export default async function AppsPage({
subtitle={`All Apps for Project "${project.name}"`}>
<CreateProjectActions projectId={projectId} />
</PageTitle>
<AppTable app={data} projectId={project.id} />
<AppTable app={relevantApps} projectId={project.id} />
<ProjectBreadcrumbs project={project} />
</div>
)

View File

@@ -18,11 +18,16 @@ import {
import { useBreadcrumbs } from "@/frontend/states/zustand.states";
import ProjectsBreadcrumbs from "./projects-breadcrumbs";
import { Plus } from "lucide-react";
import { RoleUtils } from "@/server/utils/role.utils";
export default async function ProjectPage() {
await getAuthUserSession();
const session = await getAuthUserSession();
const data = await projectService.getAllProjects();
const relevantProjectsForUser = data.filter((project) =>
project.apps.some((app) => RoleUtils.sessionHasReadAccessForApp(session, app.id)));
return (
<div className="flex-1 space-y-4 pt-6">
@@ -32,7 +37,7 @@ export default async function ProjectPage() {
<Button><Plus /> Create Project</Button>
</EditProjectDialog>
</div>
<ProjectsTable data={data} />
<ProjectsTable data={relevantProjectsForUser} />
<ProjectsBreadcrumbs />
</div>
)

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 { ServiceException } from "@/shared/model/service.exception.model";
import userService from "@/server/services/user.service";
import { UserEditModel, userEditZodModel } from "@/shared/model/user-edit.model";
@@ -10,7 +10,7 @@ import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model"
export const saveUser = async (prevState: any, inputData: UserEditModel) =>
saveFormAction(inputData, userEditZodModel, async (validatedData) => {
const { email } = await getAuthUserSession(); // check admin permission
const { email } = await getAdminUserSession();
if (validatedData.email === email) {
throw new ServiceException('Please edit your profile in the profile settings');
}
@@ -33,22 +33,21 @@ export const saveUser = async (prevState: any, inputData: UserEditModel) =>
export const saveRole = async (prevState: any, inputData: RoleEditModel) =>
saveFormAction(inputData, roleEditZodModel, async (validatedData) => {
const { email } = await getAuthUserSession(); // check admin permission
await roleService.save(validatedData);
await getAdminUserSession();
await roleService.saveWithPermissions(validatedData);
return new SuccessActionResult();
});
export const deleteUser = async (userId: string) =>
simpleAction(async () => {
await getAuthUserSession(); // todo check admin permission
await getAdminUserSession();
await userService.deleteUserById(userId);
return new SuccessActionResult();
});
export const deleteRole = async (roleId: string) =>
simpleAction(async () => {
await getAuthUserSession(); // todo check admin permission
await getAdminUserSession();
await roleService.deleteById(roleId);
return new SuccessActionResult();
});

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 S3TargetEditOverlay from "./user-edit-overlay";
import { Button } from "@/components/ui/button";
@@ -8,7 +8,6 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
import UsersTable from "./users-table";
import userService from "@/server/services/user.service";
import roleService from "@/server/services/role.service";
import UserEditOverlay from "./user-edit-overlay";
import { CircleUser, Plus, User, UserRoundCog } from "lucide-react";
import {
Tabs,
@@ -19,9 +18,9 @@ import {
import RolesTable from "./roles-table";
import appService from "@/server/services/app.service";
export default async function S3TargetsPage() {
export default async function UsersAndRolesPage() {
await getAuthUserSession(); // todo only admins
await getAdminUserSession();
const users = await userService.getAllUsers();
const roles = await roleService.getAll();
const allApps = await appService.getAll();

View File

@@ -20,16 +20,17 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { saveRole } from "./actions"
import { RoleExtended } from "@/shared/model/role-extended.model.ts"
import { RoleExtended, RolePermissionEnum } from "@/shared/model/role-extended.model.ts"
import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model"
import { App } from "@prisma/client"
import { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AppWithProjectModel } from "@/shared/model/app-extended.model"
export default function RoleEditOverlay({ children, role, apps }: {
children: React.ReactNode;
role?: RoleExtended;
apps: App[]
apps: AppWithProjectModel[]
}) {
const [isOpen, setIsOpen] = useState<boolean>(false);
@@ -53,7 +54,7 @@ export default function RoleEditOverlay({ children, role, apps }: {
if (!perm.read && !perm.readwrite) return [];
return [{
appId: perm.appId,
permission: perm.readwrite ? 'READWRITE' : 'READ'
permission: perm.readwrite ? RolePermissionEnum.READWRITE : RolePermissionEnum.READ
}];
})
}), FormUtils.getInitialFormState<typeof roleEditZodModel>());
@@ -76,8 +77,8 @@ export default function RoleEditOverlay({ children, role, apps }: {
const existingPermission = role.roleAppPermissions?.find(p => p.appId === app.id);
return {
appId: app.id,
read: !!existingPermission && existingPermission.permission === 'READ',
readwrite: !!existingPermission && existingPermission.permission === 'READWRITE'
read: !!existingPermission && (existingPermission.permission === RolePermissionEnum.READ || existingPermission.permission === RolePermissionEnum.READWRITE),
readwrite: !!existingPermission && existingPermission.permission === RolePermissionEnum.READWRITE
};
});
@@ -128,7 +129,7 @@ export default function RoleEditOverlay({ children, role, apps }: {
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(isOpened)}>
<DialogContent className="sm:max-w-[600px]">
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{role?.id ? 'Edit' : 'Create'} Role</DialogTitle>
</DialogHeader>
@@ -158,6 +159,7 @@ export default function RoleEditOverlay({ children, role, apps }: {
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>App</TableHead>
<TableHead>Read</TableHead>
<TableHead>ReadWrite</TableHead>
@@ -168,6 +170,7 @@ export default function RoleEditOverlay({ children, role, apps }: {
const permission = appPermissions.find(p => p.appId === app.id);
return (
<TableRow key={app.id}>
<TableCell>{app.project.name}</TableCell>
<TableCell>{app.name}</TableCell>
<TableCell>
<Checkbox

View File

@@ -8,13 +8,13 @@ import React from "react";
import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { formatDateTime } from "@/frontend/utils/format.utils";
import { deleteRole } from "./actions";
import { RoleExtended } from "@/shared/model/role-extended.model.ts";
import { adminRoleName, RoleExtended, RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
import RoleEditOverlay from "./role-edit-overlay";
import { App } from "@prisma/client";
import { AppWithProjectModel } from "@/shared/model/app-extended.model";
export default function RolesTable({ roles, apps }: {
roles: RoleExtended[];
apps: App[];
apps: AppWithProjectModel[];
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -34,8 +34,8 @@ export default function RolesTable({ roles, apps }: {
<SimpleDataTable columns={[
['id', 'ID', false],
['name', 'Name', true],
['roleReadPermissions', 'Read Permissions', true],
['roleWritePermissions', 'Write Permissions', true],
['roleReadPermissions', 'Read Permissions', true, (item) => item.roleAppPermissions.filter(x => x.permission === RolePermissionEnum.READ).map(p => p.app.name).join(', ')],
['roleWritePermissions', 'Write Permissions', true, (item) => item.roleAppPermissions.filter(x => x.permission === RolePermissionEnum.READWRITE).map(p => p.app.name).join(', ')],
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
]}
@@ -43,13 +43,15 @@ export default function RolesTable({ roles, apps }: {
actionCol={(item) =>
<>
<div className="flex">
<div className="flex-1"></div>
<RoleEditOverlay apps={apps} role={item} >
<Button variant="ghost"><EditIcon /></Button>
</RoleEditOverlay>
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
<TrashIcon />
</Button>
{item.name !== adminRoleName && <>
<div className="flex-1"></div>
<RoleEditOverlay apps={apps} role={item} >
<Button variant="ghost"><EditIcon /></Button>
</RoleEditOverlay>
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
<TrashIcon />
</Button>
</>}
</div>
</>}
/>

View File

@@ -3,7 +3,7 @@ import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
import { App, AppBasicAuth, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { AppExtendedModel, AppWithProjectModel } from "@/shared/model/app-extended.model";
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
@@ -470,10 +470,29 @@ class AppService {
}
async getAll() {
return await dataAccess.client.app.findMany({
const apps = await dataAccess.client.app.findMany({
orderBy: {
name: 'asc'
},
include: {
project: true,
}
}) as AppWithProjectModel[];
return apps.toSorted((a, b) => {
if (a.project.name.toLocaleLowerCase() < b.project.name.toLocaleLowerCase()) {
return -1;
}
if (a.project.name.toLocaleLowerCase() > b.project.name.toLocaleLowerCase()) {
return 1;
}
if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) {
return -1;
}
if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) {
return 1;
}
return 0;
});
}
}

View File

@@ -3,33 +3,80 @@ import dataAccess from "../adapter/db.client";
import { revalidateTag, unstable_cache } from "next/cache";
import { Tags } from "../utils/cache-tag-generator.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
export const adminRoleName = "admin";
export enum RolePersmission {
READ = "READ",
READWRITE = "READWRITE",
}
import { RoleEditModel } from "@/shared/model/role-edit.model";
import { adminRoleName } from "@/shared/model/role-extended.model.ts";
export class RoleService {
async getRoleByUserId(userId: string) {
return await unstable_cache(async (uId: string) => await dataAccess.client.role.findFirst({
async getRoleByUserMail(email: string) {
return await unstable_cache(async (mail: string) => await dataAccess.client.user.findFirst({
select: {
id: true,
name: true,
},
where: {
users: {
some: {
id: uId
role: {
select: {
name: true,
id: true,
roleAppPermissions: {
select: {
appId: true,
permission: true
}
}
}
}
},
where: {
email: mail
}
}).then(user => {
return user?.role ?? null;
}),
[Tags.roles(), Tags.users()], {
tags: [Tags.roles(), Tags.users()]
})(userId);
})(email);
}
async saveWithPermissions(item: RoleEditModel) {
try {
if (item.name === adminRoleName) {
throw new ServiceException("You cannot assign the name 'admin' to a role");
}
if (item.id) {
await dataAccess.client.role.update({
where: {
id: item.id as string
},
data: {
name: item.name,
roleAppPermissions: {
deleteMany: {},
createMany: {
data: item.roleAppPermissions?.map(p => ({
appId: p.appId,
permission: p.permission
})) || []
}
}
}
});
} else {
await dataAccess.client.role.create({
data: {
name: item.name,
roleAppPermissions: {
createMany: {
data: item.roleAppPermissions?.map(p => ({
appId: p.appId,
permission: p.permission
})) || []
}
}
}
});
}
} finally {
revalidateTag(Tags.roles());
revalidateTag(Tags.users());
}
}
async save(item: Prisma.RoleUncheckedCreateInput | Prisma.RoleUncheckedUpdateInput) {
@@ -79,7 +126,15 @@ export class RoleService {
async getAll() {
return await unstable_cache(async () => await dataAccess.client.role.findMany({
include: {
roleAppPermissions: true
roleAppPermissions: {
include: {
app: {
select: {
name: true
}
}
}
}
}
}),
[Tags.roles()], {

View File

@@ -7,7 +7,10 @@ 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, RolePersmission } from "../services/role.service";
import roleService, { adminRoleName } from "../services/role.service";
import { Role } from "@prisma/client";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
import { RoleUtils } from "./role.utils";
/**
* THIS FUNCTION RETURNS NULL IF NO USER IS LOGGED IN
@@ -18,10 +21,26 @@ export async function getUserSession(): Promise<UserSession | null> {
if (!session) {
return null;
}
type roleAppPermissions = {
appId: string;
permission: RolePermissionEnum;
};
let role: {
id: string;
name: string;
roleAppPermissions: {
appId: string;
permission: string;
}[];
} | null = null;
if (!!session?.user?.email) {
role = await roleService.getRoleByUserMail(session.user.email);
}
return {
email: session?.user?.email as string,
roleId: (session?.user as any)?.roleId as string,
roleName: (session?.user as any)?.roleName as string
roleId: role?.id,
roleName: role?.name,
permissions: role?.roleAppPermissions as roleAppPermissions[],
};
}
@@ -36,20 +55,16 @@ export async function getAuthUserSession(): Promise<UserSession> {
export async function getAdminUserSession(): Promise<UserSession> {
const session = await getAuthUserSession();
if (!isAdmin(session)) {
if (!RoleUtils.isAdmin(session)) {
console.error('User is not admin.');
throw new ServiceException('User is not authorized for this action.');
}
return session;
}
function isAdmin(session: UserSession) {
return session.roleName === adminRoleName;
}
export async function isAuthorizedForRoleId(roleId: string) {
const session = await getAuthUserSession();
if (!isAdmin(session) && session.roleId !== roleId) {
if (!RoleUtils.isAdmin(session) && session.roleId !== roleId) {
console.error('User is not authorized for role: ' + roleId);
throw new ServiceException('User is not authorized for this action.');
}
@@ -58,7 +73,7 @@ export async function isAuthorizedForRoleId(roleId: string) {
export async function isAuthorizedForRoleName(roleName: string) {
const session = await getAuthUserSession();
if (!isAdmin(session) && session.roleName !== roleName) {
if (!RoleUtils.isAdmin(session) && session.roleName !== roleName) {
console.error('User is not authorized for role: ' + roleName);
throw new ServiceException('User is not authorized for this action.');
}
@@ -67,15 +82,15 @@ export async function isAuthorizedForRoleName(roleName: string) {
export async function isAuthorizedReadForApp(appId: string) {
const session = await getAuthUserSession();
if (isAdmin(session)) {
if (RoleUtils.isAdmin(session)) {
return session;
}
if (!session.roleId) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');
}
const role = await roleService.getById(session.roleId);
if (!isAdmin(session) && !role.roleAppPermissions.some(app => app.appId === appId && app.permission === RolePersmission.READ)) {
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.');
}
@@ -84,21 +99,29 @@ export async function isAuthorizedReadForApp(appId: string) {
export async function isAuthorizedWriteForApp(appId: string) {
const session = await getAuthUserSession();
if (isAdmin(session)) {
if (RoleUtils.isAdmin(session)) {
return session;
}
if (!session.roleId) {
console.error('User is not authorized for app: ' + appId);
throw new ServiceException('User is not authorized for this action.');
}
const role = await roleService.getById(session.roleId);
if (!isAdmin(session) && !role.roleAppPermissions.some(app => app.appId === appId && app.permission === RolePersmission.READWRITE)) {
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.');
}
return session;
}
export async function safeGetUserPermissionForApp(appId: string) {
const session = await getUserSession();
if (!session) {
return null;
}
return RoleUtils.getRolePermissionForApp(session, appId);
}
export async function saveFormAction<ReturnType, TInputData, ZodType extends ZodRawShape>(
inputData: TInputData,
validationModel: ZodObject<ZodType>,

View File

@@ -55,20 +55,6 @@ export const authOptions: NextAuthOptions = {
}
})
],
callbacks: {
async jwt({ token, user }) {
// Initial sign in
return token;
},
async session({ session, token, user }) {
if (token?.sub) {
const role = await roleService.getRoleByUserId(token.sub);
(session.user as any).roleName = role?.name;
(session.user as any).roleId = role?.id;
}
return session;
},
},
adapter: PrismaAdapter(dataAccess.client),
};

View File

@@ -0,0 +1,30 @@
import { adminRoleName, RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
import { UserSession } from "@/shared/model/sim-session.model";
export class RoleUtils {
static getRolePermissionForApp(session: UserSession, appId: string) {
return (session.permissions?.find(app => app.appId === appId)?.permission ?? null) as RolePermissionEnum | null;
}
static sessionHasReadAccessForApp(session: UserSession, appId: string) {
if (this.isAdmin(session)) {
return true;
}
const rolePermission = this.getRolePermissionForApp(session, appId);
const roleHasReadAccessForApp = rolePermission === RolePermissionEnum.READ || rolePermission === RolePermissionEnum.READWRITE;
return !!roleHasReadAccessForApp;
}
static sessionHasWriteAccessForApp(session: UserSession, appId: string) {
if (this.isAdmin(session)) {
return true;
}
const rolePermission = this.getRolePermissionForApp(session, appId);
const roleHasReadAccessForApp = rolePermission === RolePermissionEnum.READWRITE;
return roleHasReadAccessForApp;
}
static isAdmin(session: UserSession) {
return session.roleName === adminRoleName;
}
}

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-zod";
import { App, Project } from "@prisma/client";
export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
project: ProjectModel,
@@ -11,3 +12,7 @@ export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
}))
export type AppExtendedModel = z.infer<typeof AppExtendedZodModel>;
export type AppWithProjectModel = App & {
project: Project;
}

View File

@@ -1,5 +1,17 @@
import { Role, RoleAppPermission, User } from "@prisma/client";
export type RoleExtended = Role & {
roleAppPermissions: RoleAppPermission[];
roleAppPermissions: (RoleAppPermission & {
app: {
name: string;
};
})[];
}
export enum RolePermissionEnum {
READ = 'READ',
READWRITE = 'READWRITE'
}
export const adminRoleName = "admin";

View File

@@ -1,7 +1,13 @@
import { RoleAppPermission } from "@prisma/client";
import { Session } from "next-auth";
import { RolePermissionEnum } from "./role-extended.model.ts";
export interface UserSession {
email: string;
roleName?: string;
roleId?: string;
permissions?: {
appId: string,
permission: RolePermissionEnum
}[];
}