scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
378 lines (339 loc) • 12.1 kB
text/typescript
// ==============================================================
// File: logger.ts
//
// Author: Jarle Elshaug
// ==============================================================
import { existsSync, renameSync, readdirSync, unlinkSync, mkdirSync, createWriteStream } from 'node:fs'
import { join } from 'node:path'
import diagnostics_channel from 'node:diagnostics_channel'
// Node does not support "export enum LogLevel"
// instead using LogLevel as object and the type "LogLevel"
export const LogLevel = {
Off: 0,
Debug: 1,
Info: 2,
Warn: 3,
Error: 4,
}
type LogLevel = typeof LogLevel[keyof typeof LogLevel]
// mapping log levels to their severity
const LEVEL_TO_INT: Record<string, LogLevel> = {
off: LogLevel.Off,
debug: LogLevel.Debug,
info: LogLevel.Info,
warn: LogLevel.Warn,
error: LogLevel.Error,
}
const COLORS: Record<string, string> = {
reset: '\x1b[0m', // Reset color
debug: '\x1b[90m', // Gray
info: '\x1b[32m', // Green
warn: '\x1b[33m', // Yellow
error: '\x1b[31m', // Red
}
interface LoggerOptions {
type: 'console' | 'file'
level: 'off' | 'debug' | 'info' | 'warn' | 'error'
category?: string
customMasking?: string[]
logFileName?: string
logDir?: string
maxSize?: number
maxFiles?: number
colorize?: boolean
}
/**
* Example:
```
const logger = new Logger(
'plugin-loki',
{
type: 'console',
level: 'error',
customMasking: null,
colorize: true,
},
{
type: 'file',
level: 'debug',
customMasking: null,
logDir: '/opt/my-scimgateway/logs',
logFileName: 'plugin-loki.log',
maxSize: 20,
maxFiles: 5,
},
)
```
*/
export class Logger {
private logStream: any // either Bun's FileSink or Node's WriteStream
private logChannel: diagnostics_channel.Channel
private category: string
private customMasking: string[] | undefined
private file: Record<string, any> | undefined
private console: Record<string, any> | undefined
private rotating = false
private buffer: string[] = []
private reJson: RegExp
private reJsonPathValue: RegExp
private reXml: RegExp
private callbacks: Set<(message: any) => Promise<void>> = new Set()
private LOG_DIR: string
private LOG_FILE_PREFIX: string
private LOG_FILE_SUFFIX: string
private LOG_FILE_NAME: string
private LOG_FILE: string
private MAX_LOG_SIZE: number
private MAX_LOG_FILES: number
private HIGH_WATER_MARK: number
constructor(category: string, ...options: LoggerOptions[]) {
this.LOG_DIR = './logs'
this.LOG_FILE_PREFIX = 'app'
this.LOG_FILE_SUFFIX = 'log'
this.LOG_FILE_NAME = this.LOG_FILE_PREFIX + '.' + this.LOG_FILE_SUFFIX
this.LOG_FILE = this.LOG_DIR + '/' + this.LOG_FILE_NAME
this.MAX_LOG_SIZE = 20 * 1024 * 1024 // 20 MB max file size
this.MAX_LOG_FILES = 5 // keep only the last 5 logs - note, new and rotated file on startup
this.HIGH_WATER_MARK = 16 * 1024 // 16KB buffer size before auto-flushing
if (!category) throw Error('Logger constructor missing mandatory category')
this.category = category
for (const option of options) {
if (option.type === 'file') {
if (option.logDir) this.LOG_DIR = option.logDir
if (option.logFileName) this.LOG_FILE_NAME = option.logFileName
this.LOG_FILE = this.LOG_DIR + '/' + this.LOG_FILE_NAME
this.LOG_FILE_PREFIX = this.LOG_FILE_NAME.substring(0, this.LOG_FILE_NAME.lastIndexOf('.'))
this.LOG_FILE_SUFFIX = this.LOG_FILE_NAME.substring(this.LOG_FILE_NAME.lastIndexOf('.') + 1)
this.file = {
level: option.level || 'off',
logSize: 0,
maxSize: option.maxSize ? option.maxSize * 1024 * 1024 : this.MAX_LOG_SIZE,
maxFiles: option.maxFiles || this.MAX_LOG_FILES,
}
} else if (option.type === 'console') {
if (option.colorize === undefined) {
if (process.stdout.isTTY) option.colorize = true
else option.colorize = false // stdout/stderr redirect
}
this.console = { level: option.level, colorize: option.colorize }
}
if (option.customMasking) this.customMasking = option.customMasking
}
let customMask = this.customMasking || []
if (!Array.isArray(customMask)) customMask = []
const jsonMaskKeys = ['password', 'access_token', 'client_secret', 'assertion', 'client_assertion', 'refresh_token']
const jsonJoinedKeys = jsonMaskKeys.concat(customMask).join('|')
const xmlMaskKeys = ['credentials', 'PasswordText', 'PasswordDigest', 'password']
const xmlJoinedKeys = xmlMaskKeys.concat(customMask).join('"?|') + '"?'
this.reJson = new RegExp(
`("(?:${jsonJoinedKeys})"\\s*:\\s*)"([^"]+)"`,
'gi',
)
// matches "path":"<maskKey>", then finds "value":"<value>" to mask it - SCIM 2.0 PATCH Operations
this.reJsonPathValue = new RegExp(
`("path"\\s*:\\s*"(?:${jsonJoinedKeys})"[^{}]*?"value"\\s*:\\s*")([^"]+)(")`,
'gi',
)
this.reXml = new RegExp(
`(<(?:\\w+:)?(${xmlJoinedKeys})[^>]*>)([^<]+)(<\\/(:?\\w+:)?\\2>)`,
'gi',
)
this.logChannel = diagnostics_channel.channel(this.category)
if (this.file && LEVEL_TO_INT[this.file.level] > 0) {
if (!existsSync(this.LOG_DIR)) mkdirSync(this.LOG_DIR, { recursive: true })
else if (existsSync(this.LOG_FILE)) this.rotateExistingLog()
if (typeof Bun !== 'undefined') { // Bun
this.logStream = Bun.file(this.LOG_FILE).writer({ highWaterMark: this.HIGH_WATER_MARK })
} else { // Node.js
this.logStream = createWriteStream(this.LOG_FILE, { flags: 'a' })
}
this.subscribe(this.logToFile)
}
if (this.console && LEVEL_TO_INT[this.console.level] > 0) {
this.subscribe(this.logToConsole)
}
}
private maskSecret(msg: string): string {
if (!msg) return msg
// Mask JSON secrets
msg = msg.replace(
this.reJson,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_, keyValuePair, value) => `${keyValuePair}"******"`,
)
// Mask JSON path/value secrets (SCIM 2.0 PATCH Operations)
if (msg.includes('"path"')) {
msg = msg.replace(
this.reJsonPathValue,
(_, prefix, value, suffix) => `${prefix}******${suffix}`,
)
}
// Mask XML/Soap secrets
// console.log('XML matches found:', msg.match(this.reXml)
if (msg.includes('<?xml')) {
msg = msg.replace(
this.reXml,
(_, startTag, tagName, value, endTag) => `${startTag}******${endTag}`,
)
}
return msg
}
private async rotateExistingLog() {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const archivedFile = `${this.LOG_DIR}/${this.LOG_FILE_PREFIX}-${timestamp}.${this.LOG_FILE_SUFFIX}`
renameSync(this.LOG_FILE, archivedFile)
this.cleanupOldLogs()
}
private async rotateLogs(isFlushRotate = false) {
if (!isFlushRotate && (this.rotating || !this.file)) return
this.rotating = true
try {
if (this.logStream) {
await this.logStream.end()
}
await this.rotateExistingLog()
if (typeof Bun !== 'undefined') {
this.logStream = Bun.file(this.LOG_FILE).writer({ highWaterMark: this.HIGH_WATER_MARK })
} else {
this.logStream = createWriteStream(this.LOG_FILE, { flags: 'a' })
}
this.flushBuffer()
} catch (error) {
console.error('Log rotation failed:', error)
} finally {
this.rotating = false
}
}
private cleanupOldLogs() {
if (!this.file) return
const logFiles = readdirSync(this.LOG_DIR)
.filter(file => file.startsWith(`${this.LOG_FILE_PREFIX}-`) && file.endsWith(`.${this.LOG_FILE_SUFFIX}`))
.sort((a, b) => b.localeCompare(a))
if (logFiles.length > this.file.maxFiles) {
logFiles.slice(this.file.maxFiles).forEach(file => unlinkSync(join(this.LOG_DIR, file)))
}
}
private flushBuffer() {
let sizeWritten = 0
while (this.buffer.length > 0) {
const str = this.buffer.shift()
if (this.file && str) {
this.logStream.write(str)
sizeWritten += Buffer.byteLength(str, 'utf-8')
if (sizeWritten >= this.file.maxSize) break
}
}
if (this.file) {
if (sizeWritten >= this.file.maxSize) {
this.rotateLogs(true)
}
this.file.logSize = sizeWritten
}
}
private logToFile = async (msgObj: Record<string, any>): Promise<boolean> => {
if (!this.file || !this.file.level || LEVEL_TO_INT[msgObj.level] < LEVEL_TO_INT[this.file.level] || LEVEL_TO_INT[this.file.level] === 0) return false
let logData = JSON.stringify(msgObj) + '\n'
if (this.rotating) {
this.buffer.push(logData)
return false
}
this.logStream.write(logData)
this.file.logSize += Buffer.byteLength(logData, 'utf-8')
// Rotate if max size reached
if (this.file.logSize >= this.file.maxSize) {
this.rotateLogs()
}
return true
}
private logToConsole = async (msgObj: Record<string, any>): Promise<boolean> => {
if (!this.console || !this.console.level || LEVEL_TO_INT[msgObj.level] < LEVEL_TO_INT[this.console.level] || LEVEL_TO_INT[this.console.level] === 0) return false
let logData = ''
if (this.console.colorize) {
const color = COLORS[msgObj.level] || COLORS.reset
logData = `${msgObj.time} ${this.category} ${color}${msgObj.level}${COLORS.reset}: ${msgObj.message}\n`
} else logData = JSON.stringify(msgObj) + '\n'
if (LEVEL_TO_INT[msgObj.level] >= LEVEL_TO_INT['error']) {
if (typeof Bun !== 'undefined') Bun.write(Bun.stderr, logData)
else process.stderr.write(logData)
} else {
if (typeof Bun !== 'undefined') Bun.write(Bun.stdout, logData)
else process.stdout.write(logData)
}
return true
}
/**
* log message with log level
* @param level log level
* @param message the message that will be logged
*/
private async log(level: 'debug' | 'info' | 'warn' | 'error', message: string, obj?: Record<string, any>) {
const time = new Date().toISOString()
message = this.maskSecret(message)
const msgObj: Record<string, any> = {
time,
level,
category: this.category,
...(obj || {}),
message,
}
this.logChannel.publish(msgObj)
}
public debug(message: string, obj?: Record<string, any>) {
this.log('debug', message, obj)
}
public info(message: string, obj?: Record<string, any>) {
this.log('info', message, obj)
}
public warn(message: string, obj?: Record<string, any>) {
this.log('warn', message, obj)
}
public error(message: string, obj?: Record<string, any>) {
this.log('error', message, obj)
}
/**
* setLoglevelConsole set console log level
* @param level log level
*/
public levelToInt(level: string): number {
return LEVEL_TO_INT[level] || LogLevel.Info
}
/**
* levelToInt returns the integer value of level
* @param level log level: "off", "debug", "info", "warn" or "error"
*/
public setLoglevelConsole(loglevel: string): void {
if (this?.console?.level) this.console.level = loglevel
}
/**
* setLoglevelFile set file log level
* @param level log level: "off", "debug", "info", "warn" or "error"
*/
public setLoglevelFile(loglevel: string): void {
if (this?.file?.level) this.file.level = loglevel
}
/**
* close will close all subscribtions and the logger
*/
public async close() {
this.callbacks.forEach(callback => this.unsubscribe(callback))
if (this.logStream) {
await this.logStream.end()
}
}
/**
* subscribe sets a callback function to be called for subscribing to JSON log message
* @param callback callback function
*/
public subscribe(callback: any) {
diagnostics_channel.subscribe(this.category, callback)
this.callbacks.add(callback)
}
/**
* unsubscribe from previous subscription callback
* @param callback callback function
*/
public unsubscribe(callback: any) {
diagnostics_channel.unsubscribe(this.category, callback)
this.callbacks.delete(callback)
}
}