diff --git a/.gitignore b/.gitignore index 532fed7..6ee2837 100644 --- a/.gitignore +++ b/.gitignore @@ -148,4 +148,5 @@ scripts/flatten.sh # Config management env.json -*.env \ No newline at end of file +*.env +.env* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 625c611..7b75194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@aws-sdk/s3-request-presigner": "^3.689.0", "@cloudcomponents/cdk-static-website": "^2.2.0", "@codegenie/serverless-express": "^4.15.0", - "@prisma/client": "^5.22.0", + "@prisma/client": "^6.0.1", "@types/aws-lambda": "^8.10.145", "aws-cdk-lib": "2.158.0", "axios": "^1.7.7", @@ -62,7 +62,7 @@ "eslint-plugin-import": "^2.29.1", "jest": "^29.7.0", "prettier": "^3.3.3", - "prisma": "^5.22.0", + "prisma": "^6.0.1", "supertest": "^7.0.0", "ts-jest": "^29.2.2", "ts-loader": "^9.5.1", @@ -4520,12 +4520,12 @@ } }, "node_modules/@prisma/client": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", - "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.1.tgz", + "integrity": "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg==", "hasInstallScript": true, "engines": { - "node": ">=16.13" + "node": ">=18.18" }, "peerDependencies": { "prisma": "*" @@ -4543,45 +4543,45 @@ "dev": true }, "node_modules/@prisma/engines": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", - "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.1.tgz", + "integrity": "sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/fetch-engine": "5.22.0", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.0.1", + "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", + "@prisma/fetch-engine": "6.0.1", + "@prisma/get-platform": "6.0.1" } }, "node_modules/@prisma/engines-version": { - "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", - "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e.tgz", + "integrity": "sha512-JmIds0Q2/vsOmnuTJYxY4LE+sajqjYKhLtdOT6y4imojqv5d/aeVEfbBGC74t8Be1uSp0OP8lxIj2OqoKbLsfQ==", "devOptional": true }, "node_modules/@prisma/engines/node_modules/@prisma/debug": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", - "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz", + "integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==", "devOptional": true }, "node_modules/@prisma/fetch-engine": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", - "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.1.tgz", + "integrity": "sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q==", "devOptional": true, "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.0.1", + "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", + "@prisma/get-platform": "6.0.1" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", - "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz", + "integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==", "devOptional": true }, "node_modules/@prisma/generator-helper": { @@ -4594,18 +4594,18 @@ } }, "node_modules/@prisma/get-platform": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", - "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.1.tgz", + "integrity": "sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg==", "devOptional": true, "dependencies": { - "@prisma/debug": "5.22.0" + "@prisma/debug": "6.0.1" } }, "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", - "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz", + "integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==", "devOptional": true }, "node_modules/@sinclair/typebox": { @@ -11098,19 +11098,19 @@ } }, "node_modules/prisma": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", - "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.1.tgz", + "integrity": "sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.22.0" + "@prisma/engines": "6.0.1" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=16.13" + "node": ">=18.18" }, "optionalDependencies": { "fsevents": "2.3.3" diff --git a/package.json b/package.json index e8ca22c..143a382 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@aws-sdk/s3-request-presigner": "^3.689.0", "@cloudcomponents/cdk-static-website": "^2.2.0", "@codegenie/serverless-express": "^4.15.0", - "@prisma/client": "^5.22.0", + "@prisma/client": "^6.0.1", "@types/aws-lambda": "^8.10.145", "aws-cdk-lib": "2.158.0", "axios": "^1.7.7", @@ -80,7 +80,7 @@ "eslint-plugin-import": "^2.29.1", "jest": "^29.7.0", "prettier": "^3.3.3", - "prisma": "^5.22.0", + "prisma": "^6.0.1", "supertest": "^7.0.0", "ts-jest": "^29.2.2", "ts-loader": "^9.5.1", diff --git a/src/api/auth/routes.ts b/src/api/auth/routes.ts index 75860b3..31709d9 100644 --- a/src/api/auth/routes.ts +++ b/src/api/auth/routes.ts @@ -83,6 +83,11 @@ router.post( // Generate a refresh token const refreshToken = await generateRefreshToken(user.id); + // and add login to log + await prisma.userLog.create({ + data: { action: 'LOGIN', userId: user.id }, + }); + // Return token and refresh token res.json({ token, refreshToken }); }, diff --git a/src/api/index.ts b/src/api/index.ts index 9271d4d..5958811 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,9 @@ import app from './apiSetup'; import { config } from './config'; +import { initialiseAdmins } from './initialise'; + +console.log('Initializing admins...'); +initialiseAdmins(); const port = config.port || 5000; diff --git a/src/api/types/auth.ts b/src/api/types/auth.ts index 823c90e..63fa7b7 100644 --- a/src/api/types/auth.ts +++ b/src/api/types/auth.ts @@ -1,3 +1,4 @@ +import { UserRole } from '@prisma/client'; import { z } from 'zod'; // Auth schemas @@ -31,11 +32,17 @@ export const ProfileResponseSchema = z.object({ user: z.object({ id: z.number(), email: z.string().email(), - // Add any other user properties you want to include }), }); export type ProfileResponse = z.infer; +export const UserDetailsSchema = z.object({ + id: z.number(), + email: z.string().email(), + roles: z.array(z.nativeEnum(UserRole)), +}); +export type UserDetails = z.infer; + // The decoded contents of a refresh token export const RefreshTokenContentsSchema = z.object({ id: z.number(), diff --git a/src/api/users/routes.ts b/src/api/users/routes.ts index ad1192b..080cbba 100644 --- a/src/api/users/routes.ts +++ b/src/api/users/routes.ts @@ -1,4 +1,4 @@ -import { UserRole } from '@prisma/client'; +import { UserAction, UserRole } from '@prisma/client'; import express, { Response } from 'express'; import { z } from 'zod'; import { processRequest } from 'zod-express-middleware'; @@ -11,6 +11,7 @@ export const router = express.Router(); import { prisma } from '../apiSetup'; import { changePassword, registerUser } from '../services/auth'; +import { UserDetailsSchema } from '../types/auth'; const UpdateUserRolesSchema = z.object({ roles: z.array(z.nativeEnum(UserRole)), @@ -34,6 +35,26 @@ const CreateUserSchema = z.object({ roles: z.array(z.nativeEnum(UserRole)).optional(), }); +export const ListUserLogsResponseSchema = z.object({ + logs: z.array( + z.object({ + id: z.number(), + userId: z.number(), + time: z.date(), + action: z.nativeEnum(UserAction), + metadata: z.any().optional(), + user: UserDetailsSchema, + }), + ), + pagination: z.object({ + page: z.number(), + limit: z.number(), + total: z.number(), + pages: z.number(), + }), +}); +export type ListUserLogsResponse = z.infer; + /** * Get all users (admin only) */ @@ -134,6 +155,11 @@ router.put( }, }); + // and add update to log + await prisma.userLog.create({ + data: { action: 'UPDATED', userId: user.id }, + }); + res.json(user); } catch (error) { handlePrismaError(error, 'Failed to update user roles.'); @@ -156,6 +182,12 @@ router.put( const { password } = req.body; await changePassword({ id: userId, password }); + + // and add password update to log + await prisma.userLog.create({ + data: { action: 'CHANGE_PASSWORD', userId: userId }, + }); + res.status(200).send(); }, ); @@ -181,3 +213,53 @@ router.delete( } }, ); + +/** + * Get user logs - optionally filter by a userId if provided. + * + * Pagination is implemented with page/limit. + * + */ +router.get( + '/utils/log', + passport.authenticate('jwt', { session: false }), + assertUserIsAdminMiddleware, + processRequest({ + query: z.object({ + userId: z.string().optional(), + page: z.string().default('1'), + limit: z.string().default('50'), + }), + }), + async (req, res: Response) => { + // Process request seems to think these are optional - which is not correct + const page = Math.max(1, parseInt(req.query.page!)); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit!))); + const skip = (page - 1) * limit; + + const where = { + ...(req.query.userId ? { userId: parseInt(req.query.userId) } : {}), + }; + + const [logs, total] = await Promise.all([ + prisma.userLog.findMany({ + where, + include: { user: { select: { roles: true, id: true, email: true } } }, + orderBy: { time: 'desc' }, + skip, + take: limit, + }), + prisma.userLog.count({ where }), + ]); + + res.json({ + logs, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }); + }, +); diff --git a/src/db/migrations/20241203020829_add_user_log/migration.sql b/src/db/migrations/20241203020829_add_user_log/migration.sql new file mode 100644 index 0000000..1559673 --- /dev/null +++ b/src/db/migrations/20241203020829_add_user_log/migration.sql @@ -0,0 +1,16 @@ +-- CreateEnum +CREATE TYPE "UserAction" AS ENUM ('LOGIN', 'LOGOUT', 'CHANGE_PASSWORD', 'UPDATED'); + +-- CreateTable +CREATE TABLE "UserLog" ( + "id" SERIAL NOT NULL, + "time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" "UserAction" NOT NULL, + "metadata" JSONB, + "userId" INTEGER NOT NULL, + + CONSTRAINT "UserLog_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "UserLog" ADD CONSTRAINT "UserLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/db/schema.prisma b/src/db/schema.prisma index d3fa087..88e9968 100644 --- a/src/db/schema.prisma +++ b/src/db/schema.prisma @@ -27,6 +27,32 @@ model User { refreshTokens RefreshToken[] jobs Job[] jobRequests JobRequest[] + log UserLog[] +} + +enum UserAction { + LOGIN + LOGOUT + CHANGE_PASSWORD + UPDATED +} + +model UserLog { + // ID for the action + id Int @id @default(autoincrement()) + + // When did this occur + time DateTime @default(now()) + + // Action type + action UserAction + + // Optional metadata field to hold any data associated with this action + metadata Json? + + // Which user was this for? + user User @relation(fields: [userId], references: [id]) + userId Int } // User submitted polygon - has notes @@ -179,4 +205,4 @@ model JobResult { storage_uri String // Optional metadata about the results metadata Json? -} \ No newline at end of file +} diff --git a/src/infra/lambda.ts b/src/infra/lambda.ts index fb3bb38..f2a720c 100644 --- a/src/infra/lambda.ts +++ b/src/infra/lambda.ts @@ -231,12 +231,6 @@ exports.handler = async (event: any, context: any) => { console.log('Importing API setup...'); const { default: app } = await import('../api/apiSetup'); - console.log('Importing initialise methods'); - const { initialiseAdmins } = await import('../api/initialise'); - - console.log('Initializing admins...'); - await initialiseAdmins(); - console.log('Creating serverless express handler...'); handler = serverlessExpress({ app }); diff --git a/test/app.test.ts b/test/app.test.ts index 92b836e..c2e8335 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -12,10 +12,12 @@ import { user2Token, userSetup, } from './utils'; -import { JobStatus, JobType } from '@prisma/client'; +import { JobStatus, JobType, UserAction } from '@prisma/client'; import { createJobResponseSchema } from '../src/api/jobs/routes'; import { JobService } from '../src/api/services/jobs'; import { randomInt } from 'crypto'; +import { ListUserLogsResponse } from '../src/api/users/routes'; +import { KeyAlgorithm } from 'aws-cdk-lib/aws-certificatemanager'; afterAll(async () => { // clear when finished @@ -260,6 +262,83 @@ describe('API', () => { } }); }); + + describe('GET /api/auth/utils/log', () => { + it('should return logs for logins and creations for user', async () => { + let log; + let res; + res = await authRequest(app, 'admin') + .get('/api/users/utils/log') + .expect(200); + log = res.body as ListUserLogsResponse; + console.log(log); + expect(log.logs.length).toEqual(0); + + // Now register a fake user + const email = 'fake@fake.com'; + let password = 'jsklfdjklsjdjsklfjdkls'; + res = await request(app) + .post('/api/auth/register') + .send({ email, password }) + .expect(200); + expect(res.body).toHaveProperty('userId'); + const userId: number = res.body.userId; + + // Now login + res = await request(app) + .post('/api/auth/login') + .send({ email, password }) + .expect(200); + + // Check the log looks good + res = await authRequest(app, 'admin') + .get('/api/users/utils/log') + .expect(200); + log = res.body as ListUserLogsResponse; + expect(log.logs.length).toEqual(1); + expect(log.logs[0].user.email).toEqual(email); + expect(log.logs[0].action).toEqual(UserAction.LOGIN); + + // update password + password = 'updateljkldsfdjskl'; + res = await authRequest(app, 'admin') + .put(`/api/users/${userId}/password`) + .send({ password }) + .expect(200); + + // Check the log looks good + res = await authRequest(app, 'admin') + .get('/api/users/utils/log') + .expect(200); + log = res.body as ListUserLogsResponse; + expect(log.logs.length).toEqual(2); + // latest first + expect(log.logs[0].user.id).toEqual(userId); + expect(log.logs[0].user.email).toEqual(email); + expect(log.logs[0].action).toEqual(UserAction.CHANGE_PASSWORD); + // older second + expect(log.logs[1].user.id).toEqual(userId); + expect(log.logs[1].user.email).toEqual(email); + expect(log.logs[1].action).toEqual(UserAction.LOGIN); + + // Now login again + res = await request(app) + .post('/api/auth/login') + .send({ email, password }) + .expect(200); + + // Check the log looks good + res = await authRequest(app, 'admin') + .get('/api/users/utils/log') + .expect(200); + log = res.body as ListUserLogsResponse; + expect(log.logs.length).toEqual(3); + // latest first + expect(log.logs[0].user.id).toEqual(userId); + expect(log.logs[0].user.email).toEqual(email); + expect(log.logs[0].action).toEqual(UserAction.LOGIN); + }); + }); }); describe('Refresh Token Utilities', () => {