From b255a969056b721908cb9182f65b5bdd2bc836da Mon Sep 17 00:00:00 2001 From: Sanjay Babu Date: Tue, 23 Jul 2024 15:53:14 -0700 Subject: [PATCH] user mgmt supervisor backend implementation --- app/src/controllers/user.ts | 37 +++++ .../20240717000000_007_access-request.ts | 51 ++++++ app/src/db/models/access_request.ts | 32 ++++ app/src/db/models/index.ts | 1 + app/src/db/models/user.ts | 12 ++ app/src/db/prisma/schema.prisma | 20 +++ app/src/routes/v1/user.ts | 18 ++- app/src/services/user.ts | 104 ++++++++++++- app/src/types/AccessRequest.ts | 10 ++ app/src/types/User.ts | 3 + app/src/types/index.ts | 1 + app/src/utils/enums/application.ts | 5 + app/src/validators/user.ts | 44 +++++- .../src/components/user/UserCreateModal.vue | 101 +++++++++--- .../src/components/user/UserManageModal.vue | 14 +- frontend/src/components/user/UserTable.vue | 46 ++++-- frontend/src/services/userService.ts | 34 +++- frontend/src/types/AccessRequest.ts | 11 ++ frontend/src/types/User.ts | 3 + frontend/src/types/index.ts | 1 + frontend/src/utils/constants/application.ts | 6 +- frontend/src/utils/enums/application.ts | 18 +++ .../src/views/user/UserManagementView.vue | 147 ++++++++++++++---- 23 files changed, 636 insertions(+), 83 deletions(-) create mode 100644 app/src/db/migrations/20240717000000_007_access-request.ts create mode 100644 app/src/db/models/access_request.ts create mode 100644 app/src/types/AccessRequest.ts create mode 100644 frontend/src/types/AccessRequest.ts diff --git a/app/src/controllers/user.ts b/app/src/controllers/user.ts index 458652be..112f3cdc 100644 --- a/app/src/controllers/user.ts +++ b/app/src/controllers/user.ts @@ -4,6 +4,34 @@ import { addDashesToUuid, mixedQueryToArray, isTruthy } from '../utils/utils'; import type { NextFunction, Request, Response } from 'express'; const controller = { + // Request to create user & access + createUserAccessRequest: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.createUserAccessRequest(req.body); + res.status(201).json(response); + } catch (e: unknown) { + next(e); + } + }, + + getNavsAndAccessRequests: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.getNavsAndAccessRequests(); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + // Request to revoke user access + revokeUserAccessRequest: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.revokeUserAccessRequest(req.body); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + searchUsers: async (req: Request, res: Response, next: NextFunction) => { try { const userIds = mixedQueryToArray(req.query.userId as string); @@ -23,6 +51,15 @@ const controller = { } catch (e: unknown) { next(e); } + }, + + updateUserRole: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.updateUserRole(req.body); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } } }; diff --git a/app/src/db/migrations/20240717000000_007_access-request.ts b/app/src/db/migrations/20240717000000_007_access-request.ts new file mode 100644 index 00000000..e6b72870 --- /dev/null +++ b/app/src/db/migrations/20240717000000_007_access-request.ts @@ -0,0 +1,51 @@ +import stamps from '../stamps'; + +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return ( + Promise.resolve() + // Create the access_request table + .then(() => + knex.schema.createTable('access_request', (table) => { + table.uuid('access_request_id').primary(); + table.uuid('user_id').notNullable().references('user_id').inTable('user'); + table.text('role'); + table + .enu('status', ['Approved', 'Pending', 'Rejected'], { + useNative: true, + enumName: 'access_request_status_enum' + }) + .defaultTo('Pending') + .notNullable(); + table.boolean('grant').notNullable(); + stamps(knex, table); + }) + ) + + .then(() => + knex.schema.raw(`create trigger before_update_access_request_trigger + before update on public.access_request + for each row execute procedure public.set_updated_at();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_access_request_trigger + AFTER UPDATE OR DELETE ON access_request + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Drop triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_access_request_trigger ON access_request')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_access_request_trigger ON access_request')) + // Drop the access_request table + .then(() => knex.schema.dropTableIfExists('access_request')) + // Drop the access_request_status_enum type + .then(() => knex.schema.raw('DROP TYPE IF EXISTS access_request_status_enum')) + ); +} diff --git a/app/src/db/models/access_request.ts b/app/src/db/models/access_request.ts new file mode 100644 index 00000000..71cf317a --- /dev/null +++ b/app/src/db/models/access_request.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; + +import type { Stamps } from '../stamps'; +import type { AccessRequest } from '../../types/AccessRequest'; // Import the access_request_status_enum type +import { UserStatus } from '../../utils/enums/application'; + +// Define types +const _accessRequest = Prisma.validator()({}); + +type PrismaRelationAccessRequest = Omit, keyof Stamps>; + +export default { + toPrismaModel(input: AccessRequest): PrismaRelationAccessRequest { + return { + access_request_id: input.accessRequestId, + grant: input.grant, + role: input.role, + status: input.status as UserStatus, // Cast the status property to UserStatus enum + user_id: input.userId + }; + }, + + fromPrismaModel(input: PrismaRelationAccessRequest): AccessRequest { + return { + accessRequestId: input.access_request_id, + grant: input.grant, + role: input.role, + userId: input.user_id as string, + status: input.status as UserStatus // Cast the status property to UserStatus enum + }; + } +}; diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index e62d513c..2d29975a 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,4 +1,5 @@ export { default as activity } from './activity'; +export { default as access_request } from './access_request'; export { default as document } from './document'; export { default as enquiry } from './enquiry'; export { default as identity_provider } from './identity_provider'; diff --git a/app/src/db/models/user.ts b/app/src/db/models/user.ts index 1a7371fe..7f8f8537 100644 --- a/app/src/db/models/user.ts +++ b/app/src/db/models/user.ts @@ -1,12 +1,16 @@ import { Prisma } from '@prisma/client'; +import access_request from './access_request'; + import type { Stamps } from '../stamps'; import type { User } from '../../types/User'; // Define types const _user = Prisma.validator()({}); +const _userWithAccessRequestGraph = Prisma.validator()({ include: { access_request: true } }); type PrismaRelationUser = Omit, keyof Stamps>; +type PrismaGraphUserAccessRequest = Prisma.userGetPayload; export default { toPrismaModel(input: User): PrismaRelationUser { @@ -35,5 +39,13 @@ export default { lastName: input.last_name, active: input.active }; + }, + fromPrismaModelWithAccessRequest(input: PrismaGraphUserAccessRequest): User { + const user = this.fromPrismaModel(input); + if (input.access_request && input.access_request.length > 0) { + // extract the 1st element from access_request array, as we are only expecting 1 element + user.accessRequest = access_request.fromPrismaModel(input.access_request[0]); + } + return user; } }; diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index 93fa188c..a147e7de 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -209,6 +209,7 @@ model user { created_at DateTime? @default(now()) @db.Timestamptz(6) updated_by String? updated_at DateTime? @db.Timestamptz(6) + access_request access_request[] enquiry enquiry[] submission submission[] identity_provider identity_provider? @relation(fields: [idp], references: [idp], onDelete: Cascade, map: "user_idp_foreign") @@ -245,3 +246,22 @@ model enquiry { activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "enquiry_activity_id_foreign") user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "enquiry_assigned_user_id_foreign") } + +model access_request { + access_request_id String @id @db.Uuid + user_id String @db.Uuid + role String? + status access_request_status_enum @default(Pending) + grant Boolean + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + user user @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction, map: "access_request_user_id_foreign") +} + +enum access_request_status_enum { + Approved + Pending + Rejected +} diff --git a/app/src/routes/v1/user.ts b/app/src/routes/v1/user.ts index b09b9c17..a6ca0460 100644 --- a/app/src/routes/v1/user.ts +++ b/app/src/routes/v1/user.ts @@ -8,9 +8,25 @@ import type { NextFunction, Request, Response } from 'express'; const router = express.Router(); router.use(requireSomeAuth); -// Submission endpoint +// Search for users router.get('/', userValidator.searchUsers, (req: Request, res: Response, next: NextFunction): void => { userController.searchUsers(req, res, next); }); +// Request to revoke a user +router.put('/request/access', userValidator.requestRevoke, (req: Request, res: Response, next: NextFunction): void => { + userController.revokeUserAccessRequest(req, res, next); +}); +// Request to create a user and access request +router.post('/request/access', userValidator.requestAccess, (req: Request, res: Response, next: NextFunction): void => { + userController.createUserAccessRequest(req, res, next); +}); +// Update user role +router.put('/role', (req: Request, res: Response, next: NextFunction): void => { + userController.updateUserRole(req, res, next); +}); + +router.get('/navsRequests', (req: Request, res: Response, next: NextFunction): void => { + userController.getNavsAndAccessRequests(req, res, next); +}); export default router; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index f69db4cb..7dccebc3 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -3,13 +3,17 @@ import { Prisma } from '@prisma/client'; import { v4 as uuidv4, NIL } from 'uuid'; import prisma from '../db/dataConnection'; -import { identity_provider, user } from '../db/models'; +import { access_request, identity_provider, user } from '../db/models'; +import { IdentityProvider, UserStatus } from '../utils/enums/application'; import { parseIdentityKeyClaims } from '../utils/utils'; -import type { User, UserSearchParameters } from '../types'; +import type { AccessRequest, User, UserSearchParameters } from '../types'; const trxWrapper = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); +// Constants +const SYSTEM_USER = 'system'; + /** * The User DB Service */ @@ -117,6 +121,47 @@ const service = { return response; }, + /** + * @function createUserAccessRequest + * Create a user DB record if it does not exist and create an access_request record + * @param {object} data Incoming user data + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createUserAccessRequest: async (data: User) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let response: any; + const oldUser = await prisma.user.findFirst({ + where: { + identity_id: data.identityId, + idp: data.idp + } + }); + + if (!oldUser) { + await prisma.$transaction(async (trx) => { + response = await service.createUser(data, trx); + + const newAccessRequest = { + accessRequestId: uuidv4(), + userId: response?.user_id, + grant: data.accessRequest?.grant as boolean, + role: data.accessRequest?.role as string, + status: UserStatus.PENDING + }; + const accessRequestResponse = await trx.access_request.create({ + data: access_request.toPrismaModel(newAccessRequest) + }); + + // eslint-disable-next-line max-len + response.access_request = [accessRequestResponse]; // Adding access request to user as an array as our model expects it + }); + return user.fromPrismaModelWithAccessRequest(response); + } else { + return user.fromPrismaModel(oldUser); + } + }, + /** * @function getCurrentUserId * Gets userId (primary identifier of a user in db) of currentUser. @@ -136,6 +181,33 @@ const service = { return user && user.user_id ? user.user_id : defaultValue; }, + /** + * @function listNavigators + * Lists all the + * @param {boolean} [active] Optional boolean on user active status + * @returns {Promise} The result of running the find operation + */ + getNavsAndAccessRequests: async () => { + const response = await prisma.user.findMany({ + include: { + access_request: { + take: 1, // Only get the latest access request for each user + orderBy: { + created_at: 'desc' + } + } + }, + where: { + active: true, + idp: IdentityProvider.IDIR, + username: { + not: SYSTEM_USER + } + } + }); + return response.map((x) => user.fromPrismaModelWithAccessRequest(x)); + }, + /** * @function listIdps * Lists all known identity providers @@ -324,6 +396,34 @@ const service = { // Nothing to update return oldUser; } + }, + /** + * @function revokeUserAccessRequest + * Updates user access + * @returns {Promise} The result of running the put operation + */ + revokeUserAccessRequest: async (data: AccessRequest) => { + const newAccessRequest = { + accessRequestId: uuidv4(), + userId: data.userId, + grant: data.grant, + role: null, + status: UserStatus.PENDING + }; + const response = await prisma.access_request.create({ + data: access_request.toPrismaModel(newAccessRequest) + }); + + return access_request.fromPrismaModel(response); + }, + /** + * @function updateUserRole + * Updates user role + * @returns {Promise} The result of running the put operation + */ + updateUserRole: async (user: User) => { + console.log(user); + // TODO: Implement updateUserRole } }; diff --git a/app/src/types/AccessRequest.ts b/app/src/types/AccessRequest.ts new file mode 100644 index 00000000..7619fc0e --- /dev/null +++ b/app/src/types/AccessRequest.ts @@ -0,0 +1,10 @@ +import { IStamps } from '../interfaces/IStamps'; +import { UserStatus } from '../utils/enums/application'; + +export type AccessRequest = { + accessRequestId: string; // Primary key + grant: boolean; + role: string | null; + status: UserStatus; + userId: string; +} & Partial; diff --git a/app/src/types/User.ts b/app/src/types/User.ts index c9b3f7a4..432af9a5 100644 --- a/app/src/types/User.ts +++ b/app/src/types/User.ts @@ -1,5 +1,7 @@ import { IStamps } from '../interfaces/IStamps'; +import type { AccessRequest } from './AccessRequest'; + export type User = { userId?: string; // Primary Key identityId: string; @@ -10,4 +12,5 @@ export type User = { fullName: string | null; lastName: string | null; active: boolean; + accessRequest?: AccessRequest | null; } & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 5690d27f..185341e7 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,4 +1,5 @@ export type { Activity } from './Activity'; +export type { AccessRequest } from './AccessRequest'; export type { BringForward } from './BringForward'; export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; diff --git a/app/src/utils/enums/application.ts b/app/src/utils/enums/application.ts index 4db04a52..caf701f5 100644 --- a/app/src/utils/enums/application.ts +++ b/app/src/utils/enums/application.ts @@ -30,6 +30,11 @@ export enum Initiative { HOUSING = 'HOUSING' } +export enum UserStatus { + APPROVED = 'Approved', + PENDING = 'Pending', + REJECTED = 'Rejected' +} export enum Regex { /** * Generic email regex modified to require domain of at least 2 characters diff --git a/app/src/validators/user.ts b/app/src/validators/user.ts index 10c9e0c3..6531480a 100644 --- a/app/src/validators/user.ts +++ b/app/src/validators/user.ts @@ -1,4 +1,5 @@ import Joi from 'joi'; + import { uuidv4 } from './common'; import { validate } from '../middleware/validation'; @@ -15,9 +16,50 @@ const schema = { lastName: Joi.string().max(255), active: Joi.string().max(255) }) + }, + requestRevoke: { + body: Joi.object({ + accessRequestId: uuidv4.allow(null), + userId: uuidv4.required(), + grant: Joi.boolean().required(), + role: Joi.string().max(255).allow(null), + status: Joi.string().max(255).allow(null) + }) + }, + requestAccess: { + body: Joi.object({ + userId: uuidv4.allow(null), + identityId: uuidv4.required(), + idp: Joi.string().max(255).required(), + username: Joi.string().max(255).required(), + email: Joi.string().max(255).required(), + firstName: Joi.string().max(255).required(), + fullName: Joi.string().max(255).required(), + lastName: Joi.string().max(255).required(), + active: Joi.string().max(255).allow(null), + accessRequest: Joi.object({ + accessRequestId: uuidv4.allow(null), + userId: uuidv4.allow(null), + grant: Joi.boolean().required(), + role: Joi.string().max(255).required(), + status: Joi.string().max(255).allow(null) + }) + }) + }, + updateRole: { + body: Joi.object({ + accessRequestId: uuidv4.allow(null), + userId: uuidv4.required(), + grant: Joi.boolean().required(), + role: Joi.string().max(255).allow(null), + status: Joi.string().max(255).allow(null) + }) } }; export default { - searchUsers: validate(schema.searchUsers) + searchUsers: validate(schema.searchUsers), + requestRevoke: validate(schema.requestRevoke), + requestAccess: validate(schema.requestAccess), + updateRole: validate(schema.updateRole) }; diff --git a/frontend/src/components/user/UserCreateModal.vue b/frontend/src/components/user/UserCreateModal.vue index 1959dd2d..7cff2448 100644 --- a/frontend/src/components/user/UserCreateModal.vue +++ b/frontend/src/components/user/UserCreateModal.vue @@ -1,10 +1,13 @@