From 1b74a32eea02fbe8c46211f9de85b6cc691ec0fc Mon Sep 17 00:00:00 2001 From: Sanjay Babu Date: Thu, 1 Aug 2024 10:29:47 -0700 Subject: [PATCH] user mgmt supervisor backend implementation --- app/src/controllers/accessRequest.ts | 46 +++++ app/src/controllers/index.ts | 1 + app/src/controllers/user.ts | 3 +- .../20240717000000_007_access-request.ts | 51 +++++ app/src/db/models/access_request.ts | 33 ++++ app/src/db/models/index.ts | 1 + app/src/db/prisma/schema.prisma | 20 ++ app/src/routes/v1/accessRequest.ts | 30 +++ app/src/routes/v1/index.ts | 14 +- app/src/routes/v1/user.ts | 2 +- app/src/services/accessRequest.ts | 59 ++++++ app/src/services/index.ts | 1 + app/src/services/user.ts | 28 +++ app/src/types/AccessRequest.ts | 10 + app/src/types/UserAccessRequest.ts | 4 + app/src/types/UserSearchParameters.ts | 1 + app/src/types/index.ts | 2 + app/src/utils/enums/application.ts | 6 + app/src/validators/accessRequest.ts | 33 ++++ app/src/validators/index.ts | 1 + app/src/validators/user.ts | 4 +- .../src/components/user/UserCreateModal.vue | 137 ++++++++++--- .../src/components/user/UserManageModal.vue | 14 +- frontend/src/components/user/UserTable.vue | 44 +++-- frontend/src/interfaces/ISSOAttribute.ts | 6 + frontend/src/services/accessRequestService.ts | 31 +++ frontend/src/services/index.ts | 1 + frontend/src/services/permissionService.ts | 7 +- frontend/src/types/AccessRequest.ts | 11 ++ frontend/src/types/BasicBCeIDAttribute.ts | 5 + frontend/src/types/BusinessBCeIDAttribute.ts | 5 + frontend/src/types/IDIRAttribute.ts | 5 + frontend/src/types/UserAccessRequest.ts | 6 + frontend/src/types/UserSearchParameters.ts | 1 + frontend/src/types/index.ts | 5 + frontend/src/utils/constants/application.ts | 10 +- frontend/src/utils/enums/application.ts | 18 ++ .../src/views/user/UserManagementView.vue | 187 ++++++++++++++---- 38 files changed, 744 insertions(+), 99 deletions(-) create mode 100644 app/src/controllers/accessRequest.ts 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/routes/v1/accessRequest.ts create mode 100644 app/src/services/accessRequest.ts create mode 100644 app/src/types/AccessRequest.ts create mode 100644 app/src/types/UserAccessRequest.ts create mode 100644 app/src/validators/accessRequest.ts create mode 100644 frontend/src/interfaces/ISSOAttribute.ts create mode 100644 frontend/src/services/accessRequestService.ts create mode 100644 frontend/src/types/AccessRequest.ts create mode 100644 frontend/src/types/BasicBCeIDAttribute.ts create mode 100644 frontend/src/types/BusinessBCeIDAttribute.ts create mode 100644 frontend/src/types/IDIRAttribute.ts create mode 100644 frontend/src/types/UserAccessRequest.ts diff --git a/app/src/controllers/accessRequest.ts b/app/src/controllers/accessRequest.ts new file mode 100644 index 00000000..7fe795b9 --- /dev/null +++ b/app/src/controllers/accessRequest.ts @@ -0,0 +1,46 @@ +import { userService, accessRequestService } from '../services'; + +import type { NextFunction, Request, Response } from 'express'; + +import type { UserAccessRequest } from '../types'; + +const controller = { + // Request to create user & access + createUserAccessRevokeRequest: async (req: Request, res: Response, next: NextFunction) => { + // TODO check if the calling user is a supervisor or an admin + try { + let response; + const { user, accessRequest } = req.body; + if (accessRequest?.grant === false) { + response = await accessRequestService.createUserAccessRevokeRequest(accessRequest); + res.status(201).json(response); + } else { + const userResponse = await userService.createUserIfNew(user); + if (userResponse) { + accessRequest.userId = userResponse.userId; + response = userResponse as UserAccessRequest; + response.accessRequest = await accessRequestService.createUserAccessRevokeRequest(accessRequest); + res.status(201).json(response); + } else { + // TODO check if the user is a proponent + // Put an entry in accessRequest table + // Send 409 if the user is not a proponent + res.status(409).json({ message: 'User already exists' }); + } + } + } catch (e: unknown) { + next(e); + } + }, + + getAccessRequests: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await accessRequestService.getAccessRequests(); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index e70dcde7..1519842c 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,3 +1,4 @@ +export { default as accessRequestController } from './accessRequest'; export { default as documentController } from './document'; export { default as enquiryController } from './enquiry'; export { default as noteController } from './note'; diff --git a/app/src/controllers/user.ts b/app/src/controllers/user.ts index 458652be..8e3d13ae 100644 --- a/app/src/controllers/user.ts +++ b/app/src/controllers/user.ts @@ -17,7 +17,8 @@ const controller = { firstName: req.query.firstName as string, fullName: req.query.fullName as string, lastName: req.query.lastName as string, - active: isTruthy(req.query.active as string) + active: isTruthy(req.query.active as string), + role: req.query.role as string }); res.status(200).json(response); } catch (e: unknown) { 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..6baf3cc9 --- /dev/null +++ b/app/src/db/models/access_request.ts @@ -0,0 +1,33 @@ +import { Prisma } from '@prisma/client'; + +import { AccessRequestStatus } from '../../utils/enums/application'; + +import type { Stamps } from '../stamps'; +import type { AccessRequest } from '../../types/AccessRequest'; // Import the access_request_status_enum type + +// 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 AccessRequestStatus, // Cast the status property to AccessRequestStatus 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 AccessRequestStatus // Cast the status property to AccessRequestStatus 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/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/accessRequest.ts b/app/src/routes/v1/accessRequest.ts new file mode 100644 index 00000000..d41c12b8 --- /dev/null +++ b/app/src/routes/v1/accessRequest.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { accessRequestController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; +import { accessRequestValidator } from '../../validators'; + +import type { NextFunction, Request, Response } from 'express'; + +const router = express.Router(); +router.use(requireSomeAuth); + +// Request to create/revoke a user and access request - called by supervisor(201) & admin(200) +router.post( + '/', + accessRequestValidator.userAccessRevokeRequest, + (req: Request, res: Response, next: NextFunction): void => { + accessRequestController.createUserAccessRevokeRequest(req, res, next); + } +); + +// Approve/Deny access/revoke request - called by admin (200) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +router.patch('/', (req: Request, res: Response, next: NextFunction): void => { + // TODO: approve/deny access request +}); + +router.get('/', (req: Request, res: Response, next: NextFunction): void => { + accessRequestController.getAccessRequests(req, res, next); +}); + +export default router; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index 082fe123..bfe23aac 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -2,6 +2,7 @@ import { currentUser } from '../../middleware/authentication'; import express from 'express'; +import accessRequest from './accessRequest'; import document from './document'; import enquiry from './enquiry'; import note from './note'; @@ -17,10 +18,21 @@ router.use(currentUser); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/document', '/enquiry', '/note', '/permit', '/roadmap', '/sso', '/submission', '/user'] + endpoints: [ + '/accessRequest', + '/document', + '/enquiry', + '/note', + '/permit', + '/roadmap', + '/sso', + '/submission', + '/user' + ] }); }); +router.use('/accessRequest', accessRequest); router.use('/document', document); router.use('/enquiry', enquiry); router.use('/note', note); diff --git a/app/src/routes/v1/user.ts b/app/src/routes/v1/user.ts index b09b9c17..cb3bcc4f 100644 --- a/app/src/routes/v1/user.ts +++ b/app/src/routes/v1/user.ts @@ -8,7 +8,7 @@ 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); }); diff --git a/app/src/services/accessRequest.ts b/app/src/services/accessRequest.ts new file mode 100644 index 00000000..56ff5929 --- /dev/null +++ b/app/src/services/accessRequest.ts @@ -0,0 +1,59 @@ +// import jwt from 'jsonwebtoken'; +// import { Prisma } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; + +import prisma from '../db/dataConnection'; +import { access_request } from '../db/models'; +import { AccessRequestStatus } from '../utils/enums/application'; + +import type { AccessRequest } from '../types'; + +/** + * The User DB Service + */ +const service = { + /** + * @function createUserAccessRequest + * Create an access_request record + * @param {object} data Incoming accessRequest data + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createUserAccessRevokeRequest: async (accessRequest: AccessRequest) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + + const newAccessRequest = { + accessRequestId: uuidv4(), + userId: accessRequest.userId, + grant: accessRequest.grant as boolean, + role: accessRequest.role as string, + status: AccessRequestStatus.PENDING + }; + const accessRequestResponse = await prisma.access_request.create({ + data: access_request.toPrismaModel(newAccessRequest) + }); + + return access_request.fromPrismaModel(accessRequestResponse); + }, + + /** + * @function getAccessRequests + * Get all access requests + * @returns {Promise} The result of running the find operation + */ + getAccessRequests: async () => { + const response = await prisma.access_request.findMany(); + return response.map((x) => access_request.fromPrismaModel(x)); + }, + + /** + * @function updateUserRole + * Updates user role + * @returns {Promise} The result of running the put operation + */ + updateUserRole: async () => { + // TODO: Implement updateUserRole + } +}; + +export default service; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index 4264a3fd..c48106c7 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,3 +1,4 @@ +export { default as accessRequestService } from './accessRequest'; export { default as activityService } from './activity'; export { default as comsService } from './coms'; export { default as documentService } from './document'; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index f69db4cb..d2517c68 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4, NIL } from 'uuid'; import prisma from '../db/dataConnection'; import { identity_provider, user } from '../db/models'; +import { AccessRole } from '../utils/enums/application'; import { parseIdentityKeyClaims } from '../utils/utils'; import type { User, UserSearchParameters } from '../types'; @@ -117,6 +118,28 @@ const service = { return response; }, + /** + * @function createUserIfNew + * Create a user DB record if it does not exist + * @param {object} data Incoming user data + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createUserIfNew: async (data: User) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let response: any; + response = await prisma.user.findFirst({ + where: { + identity_id: data.identityId, + idp: data.idp + } + }); + + if (!response) { + response = await service.createUser(data); + return user.fromPrismaModel(response); + } else return; + }, /** * @function getCurrentUserId * Gets userId (primary identifier of a user in db) of currentUser. @@ -262,6 +285,11 @@ const service = { } }); + if ((params.role as string) === AccessRole.PCNS_NAVIGATOR) { + // eslint-disable-next-line max-len + // TODO filter out any use without a role of PCNS_NAVIGATOR|PCNS_READ_ONLY or a pending access request from the response + } + return response.map((x) => user.fromPrismaModel(x)); }, diff --git a/app/src/types/AccessRequest.ts b/app/src/types/AccessRequest.ts new file mode 100644 index 00000000..5653d058 --- /dev/null +++ b/app/src/types/AccessRequest.ts @@ -0,0 +1,10 @@ +import { IStamps } from '../interfaces/IStamps'; +import { AccessRequestStatus } from '../utils/enums/application'; + +export type AccessRequest = { + accessRequestId: string; // Primary key + grant: boolean; + role: string | null; + status: AccessRequestStatus; + userId: string; +} & Partial; diff --git a/app/src/types/UserAccessRequest.ts b/app/src/types/UserAccessRequest.ts new file mode 100644 index 00000000..a0fb899d --- /dev/null +++ b/app/src/types/UserAccessRequest.ts @@ -0,0 +1,4 @@ +import type { AccessRequest, User } from '.'; +export type UserAccessRequest = { + accessRequest?: AccessRequest; +} & User; diff --git a/app/src/types/UserSearchParameters.ts b/app/src/types/UserSearchParameters.ts index a49b61aa..26648717 100644 --- a/app/src/types/UserSearchParameters.ts +++ b/app/src/types/UserSearchParameters.ts @@ -8,4 +8,5 @@ export type UserSearchParameters = { fullName?: string; lastName?: string; active?: boolean; + role?: string; }; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 5690d27f..b0757883 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'; @@ -16,4 +17,5 @@ export type { PermitType } from './PermitType'; export type { Submission } from './Submission'; export type { SubmissionSearchParameters } from './SubmissionSearchParameters'; export type { User } from './User'; +export type { UserAccessRequest } from './UserAccessRequest'; export type { UserSearchParameters } from './UserSearchParameters'; diff --git a/app/src/utils/enums/application.ts b/app/src/utils/enums/application.ts index 4db04a52..d9299a72 100644 --- a/app/src/utils/enums/application.ts +++ b/app/src/utils/enums/application.ts @@ -30,6 +30,12 @@ export enum Initiative { HOUSING = 'HOUSING' } +export enum AccessRequestStatus { + 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/accessRequest.ts b/app/src/validators/accessRequest.ts new file mode 100644 index 00000000..7554fe7a --- /dev/null +++ b/app/src/validators/accessRequest.ts @@ -0,0 +1,33 @@ +import Joi from 'joi'; + +import { uuidv4 } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + userAccessRevokeRequest: { + body: Joi.object({ + user: 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).allow(null), + status: Joi.string().max(255).allow(null) + }) + }) + } +}; + +export default { + userAccessRevokeRequest: validate(schema.userAccessRevokeRequest) +}; diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts index 5aca938a..f7f533c2 100644 --- a/app/src/validators/index.ts +++ b/app/src/validators/index.ts @@ -1,3 +1,4 @@ +export { default as accessRequestValidator } from './accessRequest'; export { default as documentValidator } from './document'; export { default as enquiryValidator } from './enquiry'; export { default as noteValidator } from './note'; diff --git a/app/src/validators/user.ts b/app/src/validators/user.ts index 10c9e0c3..3684399f 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'; @@ -13,7 +14,8 @@ const schema = { firstName: Joi.string().max(255), fullName: Joi.string().max(255), lastName: Joi.string().max(255), - active: Joi.string().max(255) + active: Joi.string().max(255), + role: Joi.string().max(255) }) } }; diff --git a/frontend/src/components/user/UserCreateModal.vue b/frontend/src/components/user/UserCreateModal.vue index 1959dd2d..474e1862 100644 --- a/frontend/src/components/user/UserCreateModal.vue +++ b/frontend/src/components/user/UserCreateModal.vue @@ -1,25 +1,78 @@