This commit is contained in:
Nicolas Meienberger 2023-03-18 13:43:59 +01:00
parent 21cba176e9
commit 38dfd93491
8 changed files with 110 additions and 20 deletions

View file

@ -16,7 +16,7 @@ FROM builder_base AS builder
WORKDIR /app
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch
RUN pnpm fetch --ingore-scripts
COPY ./package*.json ./
COPY ./prisma/schema.prisma ./prisma/

View file

@ -0,0 +1,6 @@
export const USER_PERMISSIONS = {
ADMINISTRATE_APPS: 'Can install, uninstall, start, stop and configure apps',
ADMINISTRATE_SYSTEM: 'Can restart and update the system',
} as const;
export type UserPermission = keyof typeof USER_PERMISSIONS;

View file

@ -0,0 +1,21 @@
-- Create permissions field (array of string) if it doesn't exist
ALTER TABLE
"user"
ADD
COLUMN IF NOT EXISTS "permissions" character varying [] DEFAULT NULL;
-- Set empty permissions array to users with null permissions
UPDATE
"user"
set
"permissions" = '{}'
where
"permissions" IS NULL;
-- Set permissions column to not null constraint
ALTER TABLE
"user"
ALTER COLUMN
"permissions"
SET
NOT NULL;

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import { inferRouterOutputs } from '@trpc/server';
import { AppServiceClass } from '../../services/apps/apps.service';
import { router, protectedProcedure } from '../../trpc';
import { router, protectedProcedure, permissionProcedure } from '../../trpc';
import { prisma } from '../../db/client';
export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
@ -10,18 +10,27 @@ const AppService = new AppServiceClass(prisma);
const formSchema = z.object({}).catchall(z.any());
export const appRouter = router({
// Protected
getApp: protectedProcedure.input(z.object({ id: z.string() })).query(({ input }) => AppService.getApp(input.id)),
startAllApp: protectedProcedure.mutation(AppService.startAllApps),
startApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.startApp(input.id)),
installApp: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
updateAppConfig: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
stopApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.stopApp(input.id)),
uninstallApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.uninstallApp(input.id)),
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
installedApps: protectedProcedure.query(AppService.installedApps),
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
// Permission
startApp: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string() }))
.mutation(({ input }) => AppService.startApp(input.id)),
installApp: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
updateAppConfig: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
stopApp: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string() }))
.mutation(({ input }) => AppService.stopApp(input.id)),
uninstallApp: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string() }))
.mutation(({ input }) => AppService.uninstallApp(input.id)),
updateApp: permissionProcedure('ADMINISTRATE_APPS')
.input(z.object({ id: z.string() }))
.mutation(({ input }) => AppService.updateApp(input.id)),
});

View file

@ -6,13 +6,15 @@ import { prisma } from '../../db/client';
const AuthService = new AuthServiceClass(prisma);
export const authRouter = router({
// Public
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.login({ ...input })),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.session.id)),
register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
// Protected
logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.session.id)),
refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
});

View file

@ -1,14 +1,17 @@
import { inferRouterOutputs } from '@trpc/server';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { router, protectedProcedure, publicProcedure, permissionProcedure } from '../../trpc';
import { SystemServiceClass } from '../../services/system';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
const SystemService = new SystemServiceClass();
export const systemRouter = router({
// Public
status: publicProcedure.query(SystemServiceClass.status),
systemInfo: protectedProcedure.query(SystemServiceClass.systemInfo),
getVersion: publicProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
update: protectedProcedure.mutation(SystemService.update),
// Protected
systemInfo: protectedProcedure.query(SystemServiceClass.systemInfo),
// Permission
restart: permissionProcedure('ADMINISTRATE_SYSTEM').mutation(SystemService.restart),
update: permissionProcedure('ADMINISTRATE_SYSTEM').mutation(SystemService.update),
});

View file

@ -6,6 +6,7 @@ import validator from 'validator';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
import { UserPermission } from '../../common/user-permissions';
type UsernamePasswordInput = {
username: string;
@ -94,6 +95,36 @@ export class AuthServiceClass {
return { token };
};
/*
* Creates a new user with the provided email password and permissions.
*
* @param {UsernamePasswordInput} input - An object containing the email and password fields
* @returns {Promise<User>} - A promise that resolves to the newly created user
*/
public createUser = async (input: UsernamePasswordInput & { permissions: UserPermission[] }) => {
const { password, username, permissions } = input;
const email = username.trim().toLowerCase();
if (!username || !password) {
throw new Error('Missing email or password');
}
if (username.length < 3 || !validator.isEmail(email)) {
throw new Error('Invalid username');
}
const user = await this.prisma.user.findUnique({ where: { username: email } });
if (user) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password);
const newUser = await this.prisma.user.create({ data: { username: email, password: hash, permissions } });
return newUser;
};
/**
* Retrieves the user with the provided ID
*

View file

@ -1,6 +1,8 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { UserPermission } from './common/user-permissions';
import { type Context } from './context';
import { prisma } from './db/client';
const t = initTRPC.context<Context>().create({
transformer: superjson,
@ -13,7 +15,7 @@ export const { router } = t;
/**
* Unprotected procedure
* */
*/
export const publicProcedure = t.procedure;
/**
@ -31,7 +33,23 @@ const isAuthed = t.middleware(({ ctx, next }) => {
});
});
const isAuthorized = (permission: UserPermission) =>
t.middleware(async ({ ctx, next }) => {
const user = await prisma.user.findFirst({ where: { id: Number(ctx.session?.userId) } });
// Operator is always authorized
if (user?.operator) return next();
if (!user?.permissions.includes(permission)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not authorized to perform this action' });
}
return next();
});
/**
* Protected procedure
* */
export const protectedProcedure = t.procedure.use(isAuthed);
export const permissionProcedure = (permission: UserPermission) => t.procedure.use(isAuthed).use(isAuthorized(permission));