Skip to content

Commit

Permalink
Merge pull request #605 from cisagov/implement-logging
Browse files Browse the repository at this point in the history
Implement logging for user actions
  • Loading branch information
schmelz21 authored Sep 27, 2024
2 parents 257a5af + 4dbd6bc commit c82ff3b
Show file tree
Hide file tree
Showing 9 changed files with 659 additions and 28 deletions.
155 changes: 129 additions & 26 deletions backend/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as search from './search';
import * as vulnerabilities from './vulnerabilities';
import * as organizations from './organizations';
import * as scans from './scans';
import * as logs from './logs';
import * as users from './users';
import * as scanTasks from './scan-tasks';
import * as stats from './stats';
Expand All @@ -22,12 +23,13 @@ import * as reports from './reports';
import * as savedSearches from './saved-searches';
import rateLimit from 'express-rate-limit';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { User, UserType, connectToDatabase } from '../models';
import { Organization, User, UserType, connectToDatabase } from '../models';
import * as assessments from './assessments';
import * as jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import fetch from 'node-fetch';
import * as searchOrganizations from './organizationSearch';
import { Logger, RecordMessage } from '../tools/logger';

const sanitizer = require('sanitizer');

Expand All @@ -43,27 +45,41 @@ if (
setInterval(() => scheduler({}, {} as any, () => null), 30000);
}

const handlerToExpress = (handler) => async (req, res) => {
const { statusCode, body } = await handler(
{
pathParameters: req.params,
query: req.query,
requestContext: req.requestContext,
body: JSON.stringify(req.body || '{}'),
headers: req.headers,
path: req.originalUrl
},
{}
);
try {
const parsedBody = JSON.parse(sanitizer.sanitize(body));
res.status(statusCode).json(parsedBody);
} catch (e) {
// Not a JSON body
res.setHeader('content-type', 'text/plain');
res.status(statusCode).send(sanitizer.sanitize(body));
}
};
const handlerToExpress =
(handler, message?: RecordMessage, action?: string) => async (req, res) => {
const logger = new Logger(req);
const { statusCode, body } = await handler(
{
pathParameters: req.params,
query: req.query,
requestContext: req.requestContext,
body: JSON.stringify(req.body || '{}'),
headers: req.headers,
path: req.originalUrl
},
{}
);
// Add additional status codes that we may return for succesfull requests
if (statusCode === 200) {
if (message && action) {
logger.record(action, 'success', message, body);
}
} else {
if (message && action) {
logger.record(action, 'fail', message, body);
}
}

try {
const parsedBody = JSON.parse(sanitizer.sanitize(body));
res.status(200).json(parsedBody);
} catch (e) {
// Not valid JSON - may be a string response.
console.log('Error?', e);
res.setHeader('content-type', 'text/plain');
res.status(statusCode).send(sanitizer.sanitize(body));
}
};

const app = express();

Expand Down Expand Up @@ -561,6 +577,7 @@ authenticatedRoute.delete(
handlerToExpress(savedSearches.del)
);
authenticatedRoute.get('/scans', handlerToExpress(scans.list));
authenticatedRoute.post('/logs/search', handlerToExpress(logs.list));
authenticatedRoute.get('/granularScans', handlerToExpress(scans.listGranular));
authenticatedRoute.post('/scans', handlerToExpress(scans.create));
authenticatedRoute.get('/scans/:scanId', handlerToExpress(scans.get));
Expand Down Expand Up @@ -618,12 +635,39 @@ authenticatedRoute.delete(
);
authenticatedRoute.post(
'/v2/organizations/:organizationId/users',
handlerToExpress(organizations.addUserV2)
handlerToExpress(
organizations.addUserV2,
async (req, user) => {
const orgId = req?.params?.organizationId;
const userId = req?.body?.userId;
const role = req?.body?.role;
if (orgId && userId) {
const orgRecord = await Organization.findOne({ where: { id: orgId } });
const userRecord = await User.findOne({ where: { id: userId } });
return {
timestamp: new Date(),
userPerformedAssignment: user?.data?.id,
organization: orgRecord,
role: role,
user: userRecord
};
}
return {
timestamp: new Date(),
userId: user?.data?.id,
updatePayload: req.body
};
},
'USER ASSIGNED'
)
);

authenticatedRoute.post(
'/organizations/:organizationId/roles/:roleId/approve',
handlerToExpress(organizations.approveRole)
);

// TO-DO Add logging => /users => user has an org and you change them to a new organization
authenticatedRoute.post(
'/organizations/:organizationId/roles/:roleId/remove',
handlerToExpress(organizations.removeRole)
Expand All @@ -641,9 +685,58 @@ authenticatedRoute.post(
handlerToExpress(organizations.checkDomainVerification)
);
authenticatedRoute.post('/stats', handlerToExpress(stats.get));
authenticatedRoute.post('/users', handlerToExpress(users.invite));
authenticatedRoute.post(
'/users',
handlerToExpress(
users.invite,
async (req, user, responseBody) => {
const userId = user?.data?.id;
if (userId) {
const userRecord = await User.findOne({ where: { id: userId } });
return {
timestamp: new Date(),
userPerformedInvite: userRecord,
invitePayload: req.body,
createdUserRecord: responseBody
};
}
return {
timestamp: new Date(),
userId: user.data?.id,
invitePayload: req.body,
createdUserRecord: responseBody
};
},
'USER INVITE'
)
);
authenticatedRoute.get('/users', handlerToExpress(users.list));
authenticatedRoute.delete('/users/:userId', handlerToExpress(users.del));
authenticatedRoute.delete(
'/users/:userId',
handlerToExpress(
users.del,
async (req, user, res) => {
const userId = req?.params?.userId;
const userPerformedRemovalId = user?.data?.id;
if (userId && userPerformedRemovalId) {
const userPerformdRemovalRecord = await User.findOne({
where: { id: userPerformedRemovalId }
});
return {
timestamp: new Date(),
userPerformedRemoval: userPerformdRemovalRecord,
userRemoved: userId
};
}
return {
timestamp: new Date(),
userPerformedRemoval: user.data?.id,
userRemoved: req.params.userId
};
},
'USER DENY/REMOVE'
)
);
authenticatedRoute.get(
'/users/state/:state',
handlerToExpress(users.getByState)
Expand All @@ -668,7 +761,17 @@ authenticatedRoute.post(
authenticatedRoute.put(
'/users/:userId/register/approve',
checkGlobalAdminOrRegionAdmin,
handlerToExpress(users.registrationApproval)
handlerToExpress(
users.registrationApproval,
async (req, user) => {
return {
timestamp: new Date(),
userId: user?.data?.id,
userToApprove: req.params.userId
};
},
'USER APPROVE'
)
);

authenticatedRoute.put(
Expand Down
175 changes: 175 additions & 0 deletions backend/src/api/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { SelectQueryBuilder } from 'typeorm';
import { Log } from '../models';
import { validateBody, wrapHandler } from './helpers';
import { IsDate, IsOptional, IsString } from 'class-validator';

type ParsedQuery = {
[key: string]: string | ParsedQuery;
};

const parseQueryString = (query: string): ParsedQuery => {
// Parses a query string that is used to search the JSON payload of a record
// Example => createdUserPayload.userId: 123124121424
const result: ParsedQuery = {};

const parts = query.match(/(\w+(\.\w+)*):\s*[^:]+/g);

if (!parts) {
return result;
}

parts.forEach((part) => {
const [key, value] = part.split(/:(.+)/);

if (!key || value === undefined) return;

const keyParts = key.trim().split('.');
let current = result;

keyParts.forEach((part, index) => {
if (index === keyParts.length - 1) {
current[part] = value.trim();
} else {
if (!current[part]) {
current[part] = {};
}
current = current[part] as ParsedQuery;
}
});
});

return result;
};

const generateSqlConditions = (
parsedQuery: ParsedQuery,
jsonPath: string[] = []
): string[] => {
const conditions: string[] = [];

for (const [key, value] of Object.entries(parsedQuery)) {
if (typeof value === 'object') {
const newPath = [...jsonPath, key];
conditions.push(...generateSqlConditions(value, newPath));
} else {
const jsonField =
jsonPath.length > 0
? `${jsonPath.map((path) => `'${path}'`).join('->')}->>'${key}'`
: `'${key}'`;
conditions.push(
`payload ${
jsonPath.length > 0 ? '->' : '->>'
} ${jsonField} = '${value}'`
);
}
}

return conditions;
};
class Filter {
@IsString()
value: string;

@IsString()
operator?: string;
}

class DateFilter {
@IsDate()
value: string;

@IsString()
operator:
| 'is'
| 'not'
| 'after'
| 'onOrAfter'
| 'before'
| 'onOrBefore'
| 'empty'
| 'notEmpty';
}
class LogSearch {
@IsOptional()
eventType?: Filter;
@IsOptional()
result?: Filter;
@IsOptional()
timestamp?: Filter;
@IsOptional()
payload?: Filter;
}

const generateDateCondition = (filter: DateFilter): string => {
const { operator } = filter;

switch (operator) {
case 'is':
return `log.createdAt = :timestamp`;
case 'not':
return `log.createdAt != :timestamp`;
case 'after':
return `log.createdAt > :timestamp`;
case 'onOrAfter':
return `log.createdAt >= :timestamp`;
case 'before':
return `log.createdAt < :timestamp`;
case 'onOrBefore':
return `log.createdAt <= :timestamp`;
case 'empty':
return `log.createdAt IS NULL`;
case 'notEmpty':
return `log.createdAt IS NOT NULL`;
default:
throw new Error('Invalid operator');
}
};

const filterResultQueryset = async (qs: SelectQueryBuilder<Log>, filters) => {
if (filters?.eventType) {
qs.andWhere('log.eventType ILIKE :eventType', {
eventType: `%${filters?.eventType?.value}%`
});
}
if (filters?.result) {
qs.andWhere('log.result ILIKE :result', {
result: `%${filters?.result?.value}%`
});
}
if (filters?.payload) {
try {
const parsedQuery = parseQueryString(filters?.payload?.value);
const conditions = generateSqlConditions(parsedQuery);
qs.andWhere(conditions[0]);
} catch (error) {}
}

if (filters?.timestamp) {
const timestampCondition = generateDateCondition(filters?.timestamp);
try {
} catch (error) {}
qs.andWhere(timestampCondition, {
timestamp: new Date(filters?.timestamp?.value)
});
}

return qs;
};

export const list = wrapHandler(async (event) => {
const search = await validateBody(LogSearch, event.body);

const qs = Log.createQueryBuilder('log');

const filterQs = await filterResultQueryset(qs, search);

const [results, resultsCount] = await filterQs.getManyAndCount();

return {
statusCode: 200,
body: JSON.stringify({
result: results,
count: resultsCount
})
};
});
Loading

0 comments on commit c82ff3b

Please sign in to comment.