From 742f26a2209bbe0bdee5277c00d23cf6450ccef2 Mon Sep 17 00:00:00 2001 From: Arnaud Ambroselli <31724752+arnaudambro@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:36:19 +0100 Subject: [PATCH] feat(api): user logs table (#1777) * feat(api): user logs table * cascade --- api/src/controllers/encrypt.js | 8 ++ api/src/controllers/user.js | 96 ++++++++++++------- .../20231117084743-new-table-userLog.js | 29 ++++++ api/src/db/migrations/README.md | 27 ++++++ api/src/db/sequelize.js | 1 + api/src/models/userLog.js | 24 +++++ 6 files changed, 150 insertions(+), 35 deletions(-) create mode 100644 api/src/db/migrations/20231117084743-new-table-userLog.js create mode 100644 api/src/db/migrations/README.md create mode 100644 api/src/models/userLog.js diff --git a/api/src/controllers/encrypt.js b/api/src/controllers/encrypt.js index b145d6de9..4e3e433d9 100644 --- a/api/src/controllers/encrypt.js +++ b/api/src/controllers/encrypt.js @@ -20,6 +20,7 @@ const { Report, TerritoryObservation, sequelize, + UserLog, } = require("../db/sequelize"); const { capture } = require("../sentry"); @@ -90,6 +91,13 @@ router.post( organisation.set({ encrypting: true }); await organisation.save(); + UserLog.create({ + organisation: req.user.organisation, + user: req.user._id, + platform: req.headers.platform === "android" ? "app" : req.headers.platform === "dashboard" ? "dashboard" : "unknown", + action: "change-encryption-key", + }); + try { await sequelize.transaction(async (tx) => { const { diff --git a/api/src/controllers/user.js b/api/src/controllers/user.js index 1e5e0a20a..4383186b7 100644 --- a/api/src/controllers/user.js +++ b/api/src/controllers/user.js @@ -10,7 +10,7 @@ const { validatePassword, looseUuidRegex, jwtRegex, sanitizeAll, headerJwtRegex const mailservice = require("../utils/mailservice"); const config = require("../config"); const { comparePassword } = require("../utils"); -const { User, RelUserTeam, Team, Organisation } = require("../db/sequelize"); +const { User, RelUserTeam, Team, Organisation, UserLog } = require("../db/sequelize"); const validateUser = require("../middleware/validateUser"); const { capture } = require("../sentry"); const { ExtractJwt } = require("passport-jwt"); @@ -43,7 +43,7 @@ function logoutCookieOptions() { } } -function updateUserDebugInfos(req, user) { +function createUserLog(req, user) { if (req.headers.platform === "android") { try { z.object({ @@ -72,30 +72,35 @@ function updateUserDebugInfos(req, user) { capture(e, { extra: { body: req.body }, user }); return; } - user.debugApp = { - version: req.headers.version, - apilevel: req.body.apilevel, - brand: req.body.brand, - carrier: req.body.carrier, - device: req.body.device, - deviceid: req.body.deviceid, - freediskstorage: req.body.freediskstorage, - hardware: req.body.hardware, - manufacturer: req.body.manufacturer, - maxmemory: req.body.maxmemory, - model: req.body.model, - product: req.body.product, - readableversion: req.body.readableversion, - systemname: req.body.systemname, - systemversion: req.body.systemversion, - buildid: req.body.buildid, - totaldiskcapacity: req.body.totaldiskcapacity, - totalmemory: req.body.totalmemory, - useragent: req.body.useragent, - tablet: req.body.tablet, - }; - } - if (req.headers.platform === "dashboard") { + UserLog.create({ + user: user._id, + organisation: user.organisation, + platform: "app", + action: "login", + debugApp: { + version: req.headers.version, + apilevel: req.body.apilevel, + brand: req.body.brand, + carrier: req.body.carrier, + device: req.body.device, + deviceid: req.body.deviceid, + freediskstorage: req.body.freediskstorage, + hardware: req.body.hardware, + manufacturer: req.body.manufacturer, + maxmemory: req.body.maxmemory, + model: req.body.model, + product: req.body.product, + readableversion: req.body.readableversion, + systemname: req.body.systemname, + systemversion: req.body.systemversion, + buildid: req.body.buildid, + totaldiskcapacity: req.body.totaldiskcapacity, + totalmemory: req.body.totalmemory, + useragent: req.body.useragent, + tablet: req.body.tablet, + }, + }); + } else if (req.headers.platform === "dashboard") { try { z.object({ body: z.object({ @@ -112,13 +117,26 @@ function updateUserDebugInfos(req, user) { capture(e, { extra: { body: req.body }, user }); return; } - user.debugDashboard = { - browserType: req.body.browsertype, - browserName: req.body.browsername, - browserVersion: req.body.browserversion, - browserOs: req.body.browseros, - version: req.headers.version, - }; + UserLog.create({ + user: user._id, + organisation: user.organisation, + platform: "dashboard", + action: "login", + debugDashboard: { + browserType: req.body.browsertype, + browserName: req.body.browsername, + browserVersion: req.body.browserversion, + browserOs: req.body.browseros, + version: req.headers.version, + }, + }); + } else { + UserLog.create({ + user: user._id, + organisation: user.organisation, + platform: "unknown", + action: "login", + }); } } @@ -141,7 +159,13 @@ router.post( "/logout", passport.authenticate("user", { session: false }), validateUser(["admin", "normal", "superadmin", "restricted-access"]), - catchErrors(async (_req, res) => { + catchErrors(async (req, res) => { + UserLog.create({ + organisation: req.user.organisation, + user: req.user._id, + platform: req.headers.platform === "android" ? "app" : req.headers.platform === "dashboard" ? "dashboard" : "unknown", + action: "logout", + }); res.clearCookie("jwt", logoutCookieOptions()); return res.status(200).send({ ok: true }); }) @@ -174,7 +198,7 @@ router.post( if (!match) return res.status(403).send({ ok: false, error: "E-mail ou mot de passe incorrect", code: EMAIL_OR_PASSWORD_INVALID }); user.lastLoginAt = new Date(); - updateUserDebugInfos(req, user); + createUserLog(req, user); await user.save(); // restricted-access users cannot acces the app @@ -225,6 +249,8 @@ router.get( const userTeams = await RelUserTeam.findAll({ where: { user: user._id, team: { [Op.in]: orgTeams.map((t) => t._id) } } }); const teams = userTeams.map((rel) => orgTeams.find((t) => t._id === rel.team)); + createUserLog(req, user); + return res.status(200).send({ ok: true, token, user: serializeUserWithTeamsAndOrganisation(user, teams, organisation) }); }) ); diff --git a/api/src/db/migrations/20231117084743-new-table-userLog.js b/api/src/db/migrations/20231117084743-new-table-userLog.js new file mode 100644 index 000000000..004bd86fa --- /dev/null +++ b/api/src/db/migrations/20231117084743-new-table-userLog.js @@ -0,0 +1,29 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS mano."UserLog" ( + _id uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + organisation uuid, + "user" uuid, + platform text, + action text, + "debugApp" jsonb, + "debugDashboard" jsonb, + PRIMARY KEY (_id), + CONSTRAINT "UserLog_organisation_fkey" FOREIGN KEY (organisation) REFERENCES mano."Organisation"(_id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, + CONSTRAINT "UserLog_user_fkey" FOREIGN KEY ("user") REFERENCES mano."User"(_id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE + ); + CREATE INDEX "UserLog_organisation_idx" ON mano."UserLog" USING btree (organisation); + CREATE INDEX "UserLog_user_idx" ON mano."UserLog" USING btree ("user"); + `); + }, + + async down() { + // Qui fait des down, et pourquoi ? + }, +}; diff --git a/api/src/db/migrations/README.md b/api/src/db/migrations/README.md new file mode 100644 index 000000000..d3e97a08b --- /dev/null +++ b/api/src/db/migrations/README.md @@ -0,0 +1,27 @@ +Pour ajouter une migration, faites + +```bash +npx sequelize-cli migration:generate --name mon-fichier +``` + +Puis modifiez le fichier créé dans `api/src/db/migrations/` pour ajouter les instructions SQL nécessaires. + +Par exemple + +``` +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + ALTER TABLE "mano"."User" + ADD COLUMN IF NOT EXISTS "phone" text; + `); + }, + + async down() { + // Qui fait des down, et pourquoi ? + }, +}; +``` diff --git a/api/src/db/sequelize.js b/api/src/db/sequelize.js index f6d6c6994..c5e07c620 100644 --- a/api/src/db/sequelize.js +++ b/api/src/db/sequelize.js @@ -32,6 +32,7 @@ db.TerritoryObservation = require("../models/territoryObservation")(sequelize, S db.Treatment = require("../models/treatment")(sequelize, Sequelize); db.User = require("../models/user")(sequelize, Sequelize); db.PersonBackup = require("../models/personBackup")(sequelize, Sequelize); +db.UserLog = require("../models/userLog")(sequelize, Sequelize); Object.keys(db).forEach((modelName) => { if (db[modelName].associate) { diff --git a/api/src/models/userLog.js b/api/src/models/userLog.js new file mode 100644 index 000000000..f23a2486e --- /dev/null +++ b/api/src/models/userLog.js @@ -0,0 +1,24 @@ +const { Model, Deferrable } = require("sequelize"); + +module.exports = (sequelize, DataTypes) => { + const schema = { + _id: { type: DataTypes.UUID, allowNull: false, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + organisation: { type: DataTypes.UUID, references: { model: "Organisation", key: "_id", deferrable: Deferrable.INITIALLY_IMMEDIATE } }, + user: { type: DataTypes.UUID, references: { model: "User", key: "_id", deferrable: Deferrable.INITIALLY_IMMEDIATE } }, + platform: DataTypes.TEXT, // dashboard, app + action: DataTypes.TEXT, // login, logout, change-encryption-key + debugApp: DataTypes.JSONB, + debugDashboard: DataTypes.JSONB, + }; + + class UserLog extends Model { + static associate({ Organisation, User }) { + Organisation.hasMany(UserLog, { foreignKey: { type: DataTypes.UUID, name: "organisation", field: "organisation" } }); + User.hasMany(UserLog, { foreignKey: { type: DataTypes.UUID, name: "organisation", field: "organisation" } }); + } + } + + UserLog.init(schema, { sequelize, modelName: "UserLog", freezeTableName: true, timestamps: true }); + + return UserLog; +};