Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ logging:
appServer: info
database: info
s3Storage: info
middlewares: 'info'
policies: 'info'

s3:
accessKeyId: 'secret'
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export default [
'n/no-unsupported-features/es-builtins': ['error', {
version: '>=22.1.0',
}],
'n/no-unsupported-features/node-builtins': ['error', {
version: '>=22.1.0',
}],
'@typescript-eslint/naming-convention': ['error', {
selector: 'property',
format: ['camelCase', 'PascalCase'],
Expand Down
4 changes: 4 additions & 0 deletions src/infrastructure/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export const LoggingConfig = z.object({
appServer: LoggingLevel,
database: LoggingLevel,
s3Storage: LoggingLevel,
middlewares: LoggingLevel,
policies: LoggingLevel,
});

export type LoggingConfig = z.infer<typeof LoggingConfig>;
Expand Down Expand Up @@ -161,6 +163,8 @@ const defaultConfig: AppConfig = {
appServer: 'info',
database: 'info',
s3Storage: 'info',
middlewares: 'info',
policies: 'info',
},
database: {
dsn: 'postgres://user:pass@postgres/codex-notes',
Expand Down
19 changes: 19 additions & 0 deletions src/infrastructure/logging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { pino } from 'pino';
import * as process from 'process';
import type { LoggingConfig } from '../config/index.js';
import appConfig from '../config/index.js';
import { getCurrentReqId } from './reqId.context.js';

const loggerConfig = process.env['NODE_ENV'] === 'production'
? {}
Expand Down Expand Up @@ -36,6 +37,24 @@ export function getLogger(moduleName: keyof LoggingConfig): pino.Logger {
return childLogger;
}

/**
* Creates a request-scoped logger that includes the request ID
* @param moduleName - name of the module that is logging
* @returns Logger instance with request ID context
*/
export function getRequestLogger(moduleName: keyof LoggingConfig): pino.Logger {
const baseLogger = getLogger(moduleName);
const reqId = getCurrentReqId();

if (reqId != null && reqId !== '') {
return baseLogger.child({
reqId,
});
}

return baseLogger;
}
Comment thread
7eliassen marked this conversation as resolved.

const logger = getLogger('global');

export default logger;
24 changes: 24 additions & 0 deletions src/infrastructure/logging/reqId.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Context for storing reqId within asynchronous operations
*/
interface RequestContext {
reqId?: string;
}

/**
* AsyncLocalStorage for storing request context
* Allows tying database logs to the corresponding request
*/
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();

/**
* Gets the current reqId from the execution context
* @returns reqId or undefined if context is not set
*/
export function getCurrentReqId(): string | undefined {
const context = requestContextStorage.getStore();

return context?.reqId;
}
4 changes: 3 additions & 1 deletion src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { fastifyOauth2 } from '@fastify/oauth2';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import addUserIdResolver from '@presentation/http/middlewares/common/userIdResolver.js';
import addReqIdContext from '@presentation/http/middlewares/common/reqIdContext.js';
import cookie from '@fastify/cookie';
import { notFound, forbidden, unauthorized, notAcceptable, domainError } from './decorators/index.js';
import NoteRouter from '@presentation/http/router/note.js';
Expand Down Expand Up @@ -324,7 +325,8 @@ export default class HttpApi implements Api {
throw new Error('Server is not initialized');
}

addUserIdResolver(this.server, domainServices.authService, appServerLogger);
addReqIdContext(this.server);
addUserIdResolver(this.server, domainServices.authService);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions src/presentation/http/middlewares/common/reqIdContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { FastifyInstance } from 'fastify';
import { requestContextStorage } from '@infrastructure/logging/reqId.context.js';

/**
* Adds middleware to set reqId context at the beginning of each request
* @param server - fastify instance
*/
export default function addReqIdContext(server: FastifyInstance): void {
/**
* Sets reqId context for all asynchronous operations within the request
* Uses 'onRequest' hook so context is available in all subsequent hooks and handlers
*/
server.addHook('onRequest', (request, _reply, done) => {
requestContextStorage.run({ reqId: request.id }, () => {
done();
});
});
}
6 changes: 3 additions & 3 deletions src/presentation/http/middlewares/common/userIdResolver.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import type { FastifyInstance } from 'fastify';
import type AuthService from '@domain/service/auth.js';
import type Logger from '@infrastructure/logging/index.js';
import { notEmpty } from '@infrastructure/utils/empty.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';

/**
* Add middleware for resolve userId from Access Token and add it to request
* @param server - fastify instance
* @param authService - auth domain service
* @param logger - logger instance
*/
export default function addUserIdResolver(server: FastifyInstance, authService: AuthService, logger: typeof Logger): void {
export default function addUserIdResolver(server: FastifyInstance, authService: AuthService): void {
/**
* Default userId value — null
*/
Expand All @@ -19,6 +18,7 @@ export default function addUserIdResolver(server: FastifyInstance, authService:
* Resolve userId from Access Token on each request
*/
server.addHook('preHandler', (request, _reply, done) => {
const logger = getRequestLogger('middlewares');
const authorizationHeader = request.headers.authorization;

if (notEmpty(authorizationHeader)) {
Expand Down
8 changes: 2 additions & 6 deletions src/presentation/http/middlewares/note/useNoteResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type NoteService from '@domain/service/note.js';
import { notEmpty } from '@infrastructure/utils/empty.js';
import { StatusCodes } from 'http-status-codes';
import hasProperty from '@infrastructure/utils/hasProperty.js';
import { getLogger } from '@infrastructure/logging/index.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';
import type { Note, NotePublicId } from '@domain/entities/note.js';

/**
Expand All @@ -18,11 +18,6 @@ export default function useNoteResolver(noteService: NoteService): {
*/
noteResolver: preHandlerHookHandler;
} {
/**
* Get logger instance
*/
const logger = getLogger('appServer');

/**
* Search for Note by public id in passed payload and resolves a note by it
* @param requestData - fastify request data. Can be query, params or body
Expand All @@ -40,6 +35,7 @@ export default function useNoteResolver(noteService: NoteService): {

return {
noteResolver: async function noteIdResolver(request, reply) {
const logger = getRequestLogger('middlewares');
let note: Note | undefined;

let statusCode = StatusCodes.NOT_ACCEPTABLE;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { preHandlerHookHandler } from 'fastify';
import { getLogger } from '@infrastructure/logging/index.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';
import type NoteSettingsService from '@domain/service/noteSettings.js';
import type { MemberRole } from '@domain/entities/team.js';
import { isEmpty } from '@infrastructure/utils/empty.js';
Expand All @@ -16,13 +16,9 @@ export default function useMemberRoleResolver(noteSettingsService: NoteSettingsS
*/
memberRoleResolver: preHandlerHookHandler;
} {
/**
* Get logger instance
*/
const logger = getLogger('appServer');

return {
memberRoleResolver: async function memberRoleResolver(request, reply) {
const logger = getRequestLogger('middlewares');
/** If MemberRole equals null, it means that user is not in the team or is not authenticated */
let memberRole: MemberRole | undefined;

Expand All @@ -42,7 +38,11 @@ export default function useMemberRoleResolver(noteSettingsService: NoteSettingsS
request.memberRole = memberRole;
}
} catch (error) {
logger.error('Can not resolve Member role by note [id = ${request.note.id}] and user [id = ${request.userId}]');
if (request.note != null && request.userId != null) {
logger.error(`Can not resolve Member role by note [id = ${request.note.id}] and user [id = ${request.userId}]`);
} else {
logger.error('Can not resolve Member role - note or user ID not available');
Comment thread
7eliassen marked this conversation as resolved.
Outdated
}
logger.error(error);

await reply.notAcceptable('Team member not found');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { preHandlerHookHandler } from 'fastify';
import { getLogger } from '@infrastructure/logging/index.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';
import type NoteSettingsService from '@domain/service/noteSettings.js';
import type NoteSettings from '@domain/entities/noteSettings.js';

Expand All @@ -15,13 +15,9 @@ export default function useNoteSettingsResolver(noteSettingsService: NoteSetting
*/
noteSettingsResolver: preHandlerHookHandler;
} {
/**
* Get logger instance
*/
const logger = getLogger('appServer');

return {
noteSettingsResolver: async function noteSettingsResolver(request, reply) {
const logger = getRequestLogger('middlewares');
let noteSettings: NoteSettings | null;

try {
Expand Down
6 changes: 6 additions & 0 deletions src/presentation/http/policies/authRequired.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { PolicyContext } from '@presentation/http/types/PolicyContext.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';

/**
* Policy to enforce user to be logged in
* @param context - Context object, containing Fatify request, Fastify reply and domain services
*/
export default async function authRequired(context: PolicyContext): Promise<void> {
const { request, reply } = context;
const logger = getRequestLogger('policies');

const { userId } = request;

if (userId === null) {
logger.warn('User is not authenticated');

return await reply.unauthorized();
}

logger.info(`User authenticated with ID: ${userId}`);
Comment thread
7eliassen marked this conversation as resolved.
Outdated
}
9 changes: 9 additions & 0 deletions src/presentation/http/policies/notePublicOrUserInTeam.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { isEmpty } from '@infrastructure/utils/empty.js';
import { notEmpty } from '@infrastructure/utils/empty.js';
import type { PolicyContext } from '@presentation/http/types/PolicyContext.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';

/**
* Policy to check does user have permission to access note
* @param context - Context, object containing Fatify request, Fastify reply and domain services
*/
export default async function notePublicOrUserInTeam(context: PolicyContext): Promise<void> {
const { request, reply, domainServices } = context;
const logger = getRequestLogger('policies');

const { userId } = request;

/**
* If note or noteSettings not resolved, we can't check permissions
*/
if (isEmpty(request.note) || isEmpty(request.noteSettings)) {
logger.warn('Note or note settings not found');

return await reply.notAcceptable('Note not found');
};

Expand All @@ -36,10 +40,15 @@ export default async function notePublicOrUserInTeam(context: PolicyContext): Pr
if (isPublic === false) {
/** If user is unathorized we return 401 unauthorized */
if (isEmpty(userId)) {
logger.warn('Unauthorized user trying to access private note');

return await reply.unauthorized();
/** If user is authorized, but is not in the team, we return 403 forbidden */
} else if (isEmpty(memberRole)) {
logger.warn('User not in team for private note');

return await reply.forbidden();
}
}
logger.info('Note access check completed successfully');
Comment thread
7eliassen marked this conversation as resolved.
Outdated
}
10 changes: 10 additions & 0 deletions src/presentation/http/policies/userCanEdit.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { isEmpty } from '@infrastructure/utils/empty.js';
import { MemberRole } from '@domain/entities/team.js';
import type { PolicyContext } from '@presentation/http/types/PolicyContext.js';
import { getRequestLogger } from '@infrastructure/logging/index.js';

/**
* Policy to check whether a user has permission to edit the note
* @param context - Context object, containing Fatify request, Fastify reply and domain services
*/
export default async function userCanEdit(context: PolicyContext): Promise<void> {
const { request, reply, domainServices } = context;
const logger = getRequestLogger('policies');

const { userId } = request;

/**
* If user is not authorized, we can't check his permissions
*/
if (isEmpty(userId)) {
logger.warn('User not authenticated for edit access');

return await reply.unauthorized();
};

/**
* If note is not resolved, we can't check permissions
*/
if (isEmpty(request.note)) {
logger.warn('Note not found for edit permission check');

return await reply.notAcceptable('Note not found');
};

Expand All @@ -32,6 +38,10 @@ export default async function userCanEdit(context: PolicyContext): Promise<void>
* he doesn't have permission to edit the note
*/
if (memberRole !== MemberRole.Write) {
logger.warn('User does not have write permission for note');

return await reply.forbidden();
}

logger.info('User edit permission check completed successfully');
Comment thread
7eliassen marked this conversation as resolved.
Outdated
}
12 changes: 10 additions & 2 deletions src/repository/storage/postgres/orm/sequelize/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DatabaseConfig } from '@infrastructure/config/index.js';
import { Sequelize } from 'sequelize';
import { getLogger } from '@infrastructure/logging/index.js';
import { getLogger, getRequestLogger } from '@infrastructure/logging/index.js';

const databaseLogger = getLogger('database');

Expand All @@ -26,7 +26,15 @@ export default class SequelizeOrm {
this.config = databaseConfig;

this.conn = new Sequelize(this.config.dsn, {
logging: databaseLogger.info.bind(databaseLogger),
benchmark: true,
logging: (message, timing) => {
const logger = getRequestLogger('database');

logger.info(
{ durationMs: timing },
message
);
},
Comment thread
7eliassen marked this conversation as resolved.
define: {
/**
* Use snake_case for fields in db, but camelCase in code
Expand Down
Loading