Skip to content

Commit 411c988

Browse files
feat: add API to start and stop impersonation session (#2450)
* added functionality for start and stop of impersonation * added middleware and fixed code quality * fixed bot comments * removed unused types and fixed authentication middleware jsdoc * fixed bot comments * fixed controller import * fixed build errors * fixed failing test * used constants for 403 error * fixed jsdoc for middleware
1 parent 9edc182 commit 411c988

14 files changed

+441
-63
lines changed

constants/requests.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export const UNAUTHORIZED_TO_UPDATE_REQUEST = "Unauthorized to update request";
7171

7272
export const FEATURE_NOT_IMPLEMENTED = "Feature not implemented";
7373

74-
export const IMPERSONATION_NOT_COMPLETED = "Please complete impersonation before creating a new request";
75-
export const IMPERSONATION_ALREADY_ATTEMPTED = "No active request is available for impersonation";
76-
export const IMPERSONATION_REQUEST_NOT_APPROVED = "Awaiting approval for impersonation request";
74+
export const INVALID_ACTION_PARAM = "Invalid 'action' parameter: must be either 'START' or 'STOP'";
75+
76+
export const OPERATION_NOT_ALLOWED = "You are not allowed for this operation at the moment";
77+
78+
export const IMPERSONATION_LOG_TYPE = {
79+
SESSION_STARTED:"SESSION_STARTED",
80+
SESSION_STOPPED:"SESSION_STOPPED"
81+
}

controllers/impersonationRequests.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import {
44
REQUEST_FETCHED_SUCCESSFULLY,
55
REQUEST_DOES_NOT_EXIST,
66
ERROR_WHILE_UPDATING_REQUEST,
7-
REQUEST_CREATED_SUCCESSFULLY
7+
REQUEST_CREATED_SUCCESSFULLY,
8+
OPERATION_NOT_ALLOWED
89
} from "../constants/requests";
9-
import { createImpersonationRequestService, updateImpersonationRequestService } from "../services/impersonationRequests";
10+
import { createImpersonationRequestService, updateImpersonationRequestService, generateImpersonationTokenService, startImpersonationService, stopImpersonationService } from "../services/impersonationRequests";
1011
import { getImpersonationRequestById, getImpersonationRequests } from "../models/impersonationRequests";
1112
import {
1213
CreateImpersonationRequest,
@@ -16,6 +17,7 @@ import {
1617
ImpersonationRequestResponse,
1718
GetImpersonationControllerRequest,
1819
GetImpersonationRequestByIdRequest,
20+
ImpersonationSessionRequest
1921
} from "../types/impersonationRequest";
2022
import { getPaginatedLink } from "../utils/helper";
2123
import { NextFunction } from "express";
@@ -180,3 +182,50 @@ export const updateImpersonationRequestStatusController = async (
180182
next(error);
181183
}
182184
};
185+
186+
187+
188+
/**
189+
* Controller to handle impersonation session actions (START or STOP).
190+
*
191+
* @param {ImpersonationSessionRequest} req - Express request object containing user data, query params, and impersonation flag.
192+
* @param {ImpersonationRequestResponse} res - Express response object used to send the response.
193+
* @param {NextFunction} next - Express next middleware function for error handling.
194+
* @returns {Promise<ImpersonationRequestResponse>} Sends a JSON response with updated request data and sets authentication cookies based on action.
195+
*
196+
* @throws {Forbidden} If the action is invalid or STOP is requested without an active impersonation session.
197+
*/
198+
export const impersonationController = async (
199+
req: ImpersonationSessionRequest,
200+
res: ImpersonationRequestResponse,
201+
next: NextFunction
202+
): Promise<ImpersonationRequestResponse | void> => {
203+
const { action } = req.query;
204+
const requestId = req.params.id;
205+
const userId = req.userData?.id;
206+
let authCookie;
207+
let response;
208+
try {
209+
210+
if (action === "START") {
211+
authCookie = await generateImpersonationTokenService(requestId, action);
212+
response = await startImpersonationService({ requestId, userId });
213+
}
214+
215+
if (action === "STOP") {
216+
authCookie = await generateImpersonationTokenService(requestId, action);
217+
response = await stopImpersonationService({ requestId, userId });
218+
}
219+
220+
res.clearCookie(authCookie.name);
221+
res.cookie(authCookie.name, authCookie.value, authCookie.options);
222+
223+
return res.status(200).json({
224+
message: response.returnMessage,
225+
data: response.updatedRequest
226+
});
227+
} catch (error) {
228+
logger.error(`Failed to process impersonation ${action} for requestId=${requestId}, userId=${userId}`, error);
229+
return next(error);
230+
}
231+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextFunction } from "express";
2+
import authorizeRoles from "./authorizeRoles";
3+
const { SUPERUSER } = require("../constants/roles");
4+
import { ImpersonationRequestResponse, ImpersonationSessionRequest } from "../types/impersonationRequest";
5+
import { INVALID_ACTION_PARAM, OPERATION_NOT_ALLOWED } from "../constants/requests";
6+
7+
/**
8+
* Middleware to authorize impersonation actions based on the `action` query parameter.
9+
*
10+
* - If `action=START`: Only users with the SUPERUSER role are authorized.
11+
* - If `action=STOP`: Only allowed if the user is currently impersonating someone (`req.isImpersonating === true`).
12+
* - If `action` is missing or has an invalid value: Responds with 400 Bad Request.
13+
*
14+
* @param {ImpersonationSessionRequest} req - Express request object, extended to include impersonation context.
15+
* @param {ImpersonationRequestResponse} res - Express response object with Boom error handling.
16+
* @param {NextFunction} next - Express callback to pass control to the next middleware.
17+
*
18+
* @returns {void}
19+
*/
20+
export const addAuthorizationForImpersonation = async (
21+
req: ImpersonationSessionRequest,
22+
res: ImpersonationRequestResponse,
23+
next: NextFunction
24+
) => {
25+
const { action } = req.query;
26+
27+
if (action === "START") {
28+
return authorizeRoles([SUPERUSER])(req, res, next);
29+
}
30+
31+
if (action === "STOP") {
32+
if (!req.isImpersonating) {
33+
return res.boom.forbidden(OPERATION_NOT_ALLOWED);
34+
}
35+
return next();
36+
}
37+
38+
return res.boom.badRequest(INVALID_ACTION_PARAM);
39+
};

middlewares/authenticate.js

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,82 @@
11
const authService = require("../services/authService");
22
const dataAccess = require("../services/dataAccessLayer");
3+
const logger = require("../utils/logger");
34

45
/**
5-
* Middleware to check if the user has been restricted. If user is restricted,
6-
* then only allow read requests and do not allow to any edit/create requests.
6+
* Middleware to check if the user is restricted or in an impersonation session.
7+
*
8+
* - If the user is impersonating, only GET requests and the STOP impersonation route are allowed.
9+
* - If the user is restricted (based on roles), only GET requests are permitted.
710
*
811
* Note: This requires that user is authenticated hence must be called after
912
* the user authentication middleware. We are calling it from within the
1013
* `authenticate` middleware itself to avoid explicitly adding this middleware
1114
* while defining routes.
1215
*
13-
* @param {Object} req - Express request object
14-
* @param {Object} res - Express response object
15-
* @param {Function} next - Express middleware function
16-
* @returns {Object} - Returns unauthorized object if user has been restricted.
16+
* @async
17+
* @function checkRestricted
18+
* @param {import('express').Request} req - Express request object
19+
* @param {import('express').Response} res - Express response object
20+
* @param {Function} next - Express next middleware function
21+
* @returns {void}
1722
*/
1823
const checkRestricted = async (req, res, next) => {
1924
const { roles } = req.userData;
25+
26+
if (req.isImpersonating) {
27+
const isStopImpersonationRoute =
28+
req.method === "PATCH" &&
29+
req.baseUrl === "/impersonation" &&
30+
/^\/[a-zA-Z0-9_-]+$/.test(req.path) &&
31+
req.query.action === "STOP";
32+
33+
if (req.method !== "GET" && !isStopImpersonationRoute) {
34+
return res.boom.forbidden("Only viewing is permitted during impersonation");
35+
}
36+
}
37+
2038
if (roles && roles.restricted && req.method !== "GET") {
2139
return res.boom.forbidden("You are restricted from performing this action");
2240
}
41+
2342
return next();
2443
};
2544

2645
/**
27-
* Middleware to validate the authenticated routes
28-
* 1] Verifies the token and adds user info to `req.userData` for further use
29-
* 2] In case of JWT expiry, adds a new JWT to the response if `currTime - tokenInitialisationTime <= refreshTtl`
46+
* Authentication middleware that:
47+
* 1. Verifies JWT token from cookies (or headers in non-production).
48+
* 2. Handles impersonation if applicable.
49+
* 3. Refreshes token if it's expired but still within the refresh TTL window.
50+
* 4. Attaches user data to `req.userData` for downstream use.
3051
*
31-
* The currently implemented mechanism satisfies the current use case.
32-
* Authentication with JWT and a refreshToken to be added once we have user permissions and authorizations to be handled
33-
*
34-
* @todo: Add tests to assert on refreshed JWT generation by modifying the TTL values for the specific test. Currently not possible in the absence of a test-suite.
35-
*
36-
*
37-
* @param req {Object} - Express request object
38-
* @param res {Object} - Express response object
39-
* @param next {Function} - Express middleware function
40-
* @return {Object} - Returns unauthenticated object if token is invalid
52+
* @async
53+
* @function
54+
* @param {import('express').Request} req - Express request object
55+
* @param {import('express').Response} res - Express response object
56+
* @param {Function} next - Express next middleware function
57+
* @returns {Promise<void>} - Calls `next()` on successful authentication or returns an error response.
4158
*/
4259
module.exports = async (req, res, next) => {
4360
try {
4461
let token = req.cookies[config.get("userToken.cookieName")];
4562

4663
/**
47-
* Enable Bearer Token authentication for NON-PRODUCTION environments
48-
* This is enabled as Swagger UI does not support cookie authe
64+
* Enable Bearer Token authentication for NON-PRODUCTION environments.
65+
* Useful for Swagger or manual testing where cookies are not easily managed.
4966
*/
5067
if (process.env.NODE_ENV !== "production" && !token) {
51-
token = req.headers.authorization.split(" ")[1];
68+
token = req.headers.authorization?.split(" ")[1];
5269
}
5370

54-
const { userId } = authService.verifyAuthToken(token);
71+
const { userId, impersonatedUserId } = authService.verifyAuthToken(token);
72+
// `req.isImpersonating` keeps track of the impersonation session
73+
req.isImpersonating = Boolean(impersonatedUserId);
5574

56-
// add user data to `req.userData` for further use
57-
const userData = await dataAccess.retrieveUsers({ id: userId });
58-
req.userData = userData.user;
75+
const userData = impersonatedUserId
76+
? await dataAccess.retrieveUsers({ id: impersonatedUserId })
77+
: await dataAccess.retrieveUsers({ id: userId });
5978

79+
req.userData = userData.user;
6080
return checkRestricted(req, res, next);
6181
} catch (err) {
6282
logger.error(err);
@@ -78,14 +98,13 @@ module.exports = async (req, res, next) => {
7898
sameSite: "lax",
7999
});
80100

81-
// add user data to `req.userData` for further use
82-
req.userData = await dataAccess.retrieveUsers({ id: userId });
101+
const userData = await dataAccess.retrieveUsers({ id: userId });
102+
req.userData = userData.user;
103+
83104
return checkRestricted(req, res, next);
84-
} else {
85-
return res.boom.unauthorized("Unauthenticated User");
86105
}
87-
} else {
88106
return res.boom.unauthorized("Unauthenticated User");
89107
}
108+
return res.boom.unauthorized("Unauthenticated User");
90109
}
91110
};

middlewares/validators/impersonationRequests.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import joi from "joi";
22
import { NextFunction } from "express";
3-
import { CreateImpersonationRequest,GetImpersonationControllerRequest,GetImpersonationRequestByIdRequest,ImpersonationRequestResponse, UpdateImpersonationRequest } from "../../types/impersonationRequest";
3+
import { CreateImpersonationRequest,GetImpersonationControllerRequest,GetImpersonationRequestByIdRequest,ImpersonationRequestResponse, UpdateImpersonationRequest, ImpersonationSessionRequest } from "../../types/impersonationRequest";
44
import { REQUEST_STATE } from "../../constants/requests";
55
const logger = require("../../utils/logger");
66

@@ -130,3 +130,42 @@ export const updateImpersonationRequestValidator=async (
130130
return res.boom.badRequest(errorMessages);
131131
}
132132
}
133+
134+
135+
136+
/**
137+
* Middleware to validate query parameters for impersonation session actions.
138+
*
139+
* @param {ImpersonationSessionRequest} req - Express request object containing query params
140+
* @param {ImpersonationRequestResponse} res - Express response object used to send validation errors
141+
* @param {NextFunction} next - Express next middleware function
142+
* @returns {Promise<void>} - Resolves if validation succeeds, otherwise sends an error response
143+
*/
144+
export const impersonationSessionValidator = async (
145+
req: ImpersonationSessionRequest,
146+
res: ImpersonationRequestResponse,
147+
next: NextFunction
148+
): Promise<void> => {
149+
const querySchema = joi
150+
.object()
151+
.strict()
152+
.keys({
153+
action: joi
154+
.string()
155+
.valid("START", "STOP")
156+
.required()
157+
.messages({
158+
"any.only": "action must be START or STOP",
159+
}),
160+
dev: joi.string().optional(),
161+
});
162+
163+
try {
164+
await querySchema.validateAsync(req.query, { abortEarly: false });
165+
next();
166+
} catch (error) {
167+
const errorMessages = error.details.map((detail: { message: string }) => detail.message);
168+
logger.error(`Error while validating request payload: ${errorMessages}`);
169+
return res.boom.badRequest(errorMessages);
170+
}
171+
};

models/impersonationRequests.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import firestore from "../utils/firestore";
22
import {
33
ERROR_WHILE_CREATING_REQUEST,
4+
REQUEST_ALREADY_PENDING,
5+
REQUEST_STATE,
6+
ERROR_WHILE_FETCHING_REQUEST,
47
ERROR_WHILE_UPDATING_REQUEST,
5-
ERROR_WHILE_FETCHING_REQUEST
8+
OPERATION_NOT_ALLOWED
69
} from "../constants/requests";
710
import { Timestamp } from "firebase-admin/firestore";
811
import { Query, CollectionReference } from '@google-cloud/firestore';
9-
import { CreateImpersonationRequestModelDto, ImpersonationRequest, UpdateImpersonationRequestModelDto, PaginatedImpersonationRequests,ImpersonationRequestQuery} from "../types/impersonationRequest";
12+
import { CreateImpersonationRequestModelDto, ImpersonationRequest, UpdateImpersonationRequestModelDto, PaginatedImpersonationRequests,ImpersonationRequestQuery } from "../types/impersonationRequest";
1013
import { Forbidden } from "http-errors";
1114
const logger = require("../utils/logger");
1215
const impersonationRequestModel = firestore.collection("impersonationRequests");
@@ -36,7 +39,7 @@ export const createImpersonationRequest = async (
3639
const snapshot = await reqQuery.get();
3740

3841
if (!snapshot.empty) {
39-
throw new Forbidden("You are not allowed for this Operation at the moment");
42+
throw new Forbidden(OPERATION_NOT_ALLOWED);
4043
}
4144

4245
const requestBody = {

routes/impersonation.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import express from "express";
2-
import { createImpersonationRequestValidator, getImpersonationRequestByIdValidator, getImpersonationRequestsValidator, updateImpersonationRequestValidator } from "../middlewares/validators/impersonationRequests";
2+
import { createImpersonationRequestValidator, getImpersonationRequestByIdValidator, getImpersonationRequestsValidator, updateImpersonationRequestValidator, impersonationSessionValidator } from "../middlewares/validators/impersonationRequests";
33
import authenticate from "../middlewares/authenticate";
4-
import { createImpersonationRequestController, getImpersonationRequestByIdController, getImpersonationRequestsController, updateImpersonationRequestStatusController } from "../controllers/impersonationRequests";
4+
import { createImpersonationRequestController, getImpersonationRequestByIdController, getImpersonationRequestsController, impersonationController, updateImpersonationRequestStatusController } from "../controllers/impersonationRequests";
5+
import { addAuthorizationForImpersonation } from "../middlewares/addAuthorizationForImpersonation";
56
const router = express.Router();
67
const authorizeRoles = require("../middlewares/authorizeRoles");
78
const { SUPERUSER } = require("../constants/roles");
@@ -35,4 +36,12 @@ router.patch(
3536
updateImpersonationRequestStatusController
3637
);
3738

39+
router.patch(
40+
"/:id",
41+
authenticate,
42+
impersonationSessionValidator,
43+
addAuthorizationForImpersonation,
44+
impersonationController
45+
);
46+
3847
module.exports = router;

services/authService.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ const generateAuthToken = (payload) => {
1212
});
1313
};
1414

15+
/**
16+
* Generates a short-lived JWT token for impersonation sessions.
17+
*
18+
* @param {Object} payload - The payload to include in the JWT (e.g., userId, impersonatedUserId).
19+
* @param {string} payload.userId - The ID of the super-user initiating the impersonation.
20+
* @param {string} payload.impersonatedUserId - The ID of the user being impersonated.
21+
* @returns {string} - The generated JWT for impersonation, signed using RS256 algorithm.
22+
*/
23+
const generateImpersonationAuthToken = (payload) => {
24+
return jwt.sign(payload, config.get("userToken.privateKey"), {
25+
algorithm: "RS256",
26+
expiresIn: config.get("userToken.impersonationTtl"),
27+
});
28+
};
29+
1530
/**
1631
* Verifies if the JWT is valid. Throws error in case of signature error or expiry
1732
*
@@ -36,4 +51,5 @@ module.exports = {
3651
generateAuthToken,
3752
verifyAuthToken,
3853
decodeAuthToken,
54+
generateImpersonationAuthToken,
3955
};

0 commit comments

Comments
 (0)