mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔐 fix: Preserve Structured JWT Auth Context (#14652)
* 🔐 fix: Preserve structured JWT auth context
* fix: Omit identity from auth correlation logs
* fix: Isolate pre-auth request context
* style: Sort auth context imports
* fix: Preserve structured auth metadata
* fix: Narrow structured log formatter types
* fix: Annotate structured log context keys
* test: Fix request fixture typing
* fix: Namespace request path log context
* fix: Namespace request method log context
* fix: Preserve captured tenant error paths
* style: Sort tenant error imports
* fix: Classify bulk tenant isolation failures
* fix: Enforce safe request correlation invariants
This commit is contained in:
parent
45cc53c40b
commit
dd159c4566
20 changed files with 1104 additions and 393 deletions
|
|
@ -23,6 +23,7 @@ const {
|
|||
loadToolApprovalHooks,
|
||||
maybeInjectQueryDevtoolsBootstrap,
|
||||
preAuthTenantMiddleware,
|
||||
requestContextMiddleware,
|
||||
configureServerTimeouts,
|
||||
configureMessageFilterRegexValidator,
|
||||
} = require('@librechat/api');
|
||||
|
|
@ -358,6 +359,7 @@ if (cluster.isMaster) {
|
|||
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
||||
|
||||
/** Middleware */
|
||||
app.use(requestContextMiddleware);
|
||||
app.use(noIndex);
|
||||
app.use(express.json({ limit: '3mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const {
|
|||
loadToolApprovalHooks,
|
||||
maybeInjectQueryDevtoolsBootstrap,
|
||||
preAuthTenantMiddleware,
|
||||
requestContextMiddleware,
|
||||
registerShutdownTask,
|
||||
configureServerTimeouts,
|
||||
setupGracefulShutdown,
|
||||
|
|
@ -204,6 +205,7 @@ const startServer = async () => {
|
|||
});
|
||||
|
||||
/* Middleware */
|
||||
app.use(requestContextMiddleware);
|
||||
app.use('/api/agents/chat', agentStartupIngressMiddleware);
|
||||
app.use(metricsMiddleware);
|
||||
app.use(noIndex);
|
||||
|
|
|
|||
|
|
@ -54,152 +54,7 @@ jest.mock('@librechat/data-schemas', () => {
|
|||
// primitives. The real implementation is covered by packages/api tenant.spec.ts.
|
||||
jest.mock('@librechat/api', () => {
|
||||
const { tenantStorage } = require('@librechat/data-schemas');
|
||||
const normalizeAuthLogValue = (value) => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const normalized = normalizeAuthLogValue(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizeAuthLogContextValue = (value) => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => normalizeAuthLogValue(entry))
|
||||
.filter((entry) => entry !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return normalizeAuthLogValue(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getAuthFailureField = (source, field) => {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
return field === 'message' ? source : undefined;
|
||||
}
|
||||
if (typeof source === 'object') {
|
||||
try {
|
||||
return source[field];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getAuthFailureReason = (err, info, fallback = 'Unauthorized') =>
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'message')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'message')) ??
|
||||
fallback;
|
||||
const getAuthFailureErrorName = (err, info) =>
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'name')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'name'));
|
||||
const getSafeTokenProvider = (tokenProvider) => {
|
||||
const normalized = normalizeAuthLogValue(tokenProvider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
|
||||
};
|
||||
const normalizeRoutePath = (path) => {
|
||||
if (typeof path === 'string') {
|
||||
return normalizeAuthLogValue(path);
|
||||
}
|
||||
if (Array.isArray(path)) {
|
||||
for (const entry of path) {
|
||||
const normalized = normalizeRoutePath(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const joinRoutePath = (baseUrl, routePath) => {
|
||||
const normalizedRoute = routePath === '/' ? '' : routePath;
|
||||
if (!baseUrl) {
|
||||
return normalizedRoute || '/';
|
||||
}
|
||||
if (!normalizedRoute) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`;
|
||||
};
|
||||
const bucketConcretePath = (path) => {
|
||||
const queryless = path?.split('?')[0];
|
||||
if (!queryless) {
|
||||
return undefined;
|
||||
}
|
||||
const segments = queryless.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (segments[0] === 'api' && segments[1]) {
|
||||
return `/${segments.slice(0, 2).join('/')}`;
|
||||
}
|
||||
return `/${segments[0]}`;
|
||||
};
|
||||
const getRequestPath = (req) => {
|
||||
const baseUrl = normalizeAuthLogValue(req.baseUrl);
|
||||
const routePath = normalizeRoutePath(req.route?.path);
|
||||
if (routePath) {
|
||||
return joinRoutePath(baseUrl, routePath);
|
||||
}
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
const path =
|
||||
normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
|
||||
return bucketConcretePath(path);
|
||||
};
|
||||
const compactAuthLogContext = (log) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(log)
|
||||
.map(([key, value]) => [key, normalizeAuthLogContextValue(value)])
|
||||
.filter(([, value]) => value !== undefined),
|
||||
);
|
||||
const buildSafeAuthLogContext = (req, authState, extra = {}) =>
|
||||
compactAuthLogContext({
|
||||
...extra,
|
||||
request_id:
|
||||
normalizeAuthLogValue(req.requestId) ??
|
||||
normalizeAuthLogValue(req.id) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-request-id']) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-correlation-id']),
|
||||
method: normalizeAuthLogValue(req.method),
|
||||
path: getRequestPath(req),
|
||||
token_provider: getSafeTokenProvider(authState.tokenProvider),
|
||||
openid_reuse_enabled: authState.openidReuseEnabled,
|
||||
openid_jwt_available: authState.openidJwtAvailable,
|
||||
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
|
||||
});
|
||||
const formatAuthLogMessage = (message, context) => `${message} ${JSON.stringify(context)}`;
|
||||
const actualApi = jest.requireActual('@librechat/api');
|
||||
const normalizeContextValue = (value) => {
|
||||
const trimmed = value?.trim?.();
|
||||
return trimmed || undefined;
|
||||
|
|
@ -214,10 +69,8 @@ jest.mock('@librechat/api', () => {
|
|||
return {
|
||||
isEnabled: jest.fn(() => false),
|
||||
recordRumProxyRequest: jest.fn(),
|
||||
getAuthFailureReason,
|
||||
getAuthFailureErrorName,
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
getAuthFailureReasonCategory: actualApi.getAuthFailureReasonCategory,
|
||||
buildSafeAuthLogContext: actualApi.buildSafeAuthLogContext,
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()),
|
||||
tenantContextMiddleware: (req, res, next) => {
|
||||
const context = {
|
||||
|
|
@ -371,15 +224,17 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getTenantId()).toBeUndefined();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] Authentication failed after all strategies',
|
||||
event_name: 'jwt_auth_rejected',
|
||||
primary_strategy: 'jwt',
|
||||
fallback_attempted: false,
|
||||
fallback_succeeded: false,
|
||||
attempted_strategies: ['jwt'],
|
||||
final_strategy: 'jwt',
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
reason_category: 'missing_or_unrecognized_token',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
|
|
@ -393,6 +248,7 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
headers: {
|
||||
authorization: 'Bearer valid-openid-token',
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
|
|
@ -413,40 +269,40 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] OpenID JWT auth failed; trying fallback',
|
||||
event_name: 'jwt_auth_fallback_attempt',
|
||||
request_id: 'req-expired-success',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
token_provider: 'openid',
|
||||
token_source: 'bearer',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
reason_category: 'expired_jwt',
|
||||
recovery_classification: 'fallback_attempted',
|
||||
strategy_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure',
|
||||
event_name: 'jwt_auth_recovered',
|
||||
request_id: 'req-expired-success',
|
||||
auth_strategy: 'jwt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'jwt expired',
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
primary_failure_reason_category: 'expired_jwt',
|
||||
recovery_classification: 'fallback_succeeded',
|
||||
}),
|
||||
);
|
||||
expect(logger.debug.mock.calls[0][0]).toContain('"reason":"jwt expired"');
|
||||
expect(logger.debug.mock.calls[0][0]).toContain('"fallback_attempted":true');
|
||||
expect(logger.debug.mock.calls[1][0]).toContain('"fallback_succeeded":true');
|
||||
expect(JSON.stringify(logger.debug.mock.calls)).not.toContain('jwt expired');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -491,20 +347,20 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] OpenID JWT auth failed; trying fallback',
|
||||
request_id: 'req-malformed-info',
|
||||
fallback_attempted: true,
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
reason_category: 'missing_or_unrecognized_token',
|
||||
strategy_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure',
|
||||
request_id: 'req-malformed-info',
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'Unauthorized',
|
||||
primary_failure_reason_category: 'missing_or_unrecognized_token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -540,23 +396,24 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] OpenID JWT auth failed; trying fallback',
|
||||
event_name: 'jwt_auth_fallback_attempt',
|
||||
request_id: 'req-expired-fail',
|
||||
method: 'POST',
|
||||
path: '/api/ask',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/ask',
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
reason_category: 'expired_jwt',
|
||||
strategy_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] Authentication failed after all strategies',
|
||||
event_name: 'jwt_auth_rejected',
|
||||
request_id: 'req-expired-fail',
|
||||
method: 'POST',
|
||||
path: '/api/ask',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/ask',
|
||||
token_provider: 'openid',
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
final_strategy: 'jwt',
|
||||
|
|
@ -564,24 +421,23 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
// The real openidJwt failure is surfaced alongside the fallback's reason so a
|
||||
// reused-token failure is not misattributed to the `jwt` fallback's error (#14311).
|
||||
primary_failure_reason: 'jwt expired',
|
||||
primary_failure_error_name: 'TokenExpiredError',
|
||||
reason: 'invalid signature',
|
||||
error_name: 'JsonWebTokenError',
|
||||
status: 401,
|
||||
primary_failure_reason_category: 'expired_jwt',
|
||||
reason_category: 'malformed_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn.mock.calls[0][0]).toContain('"reason":"invalid signature"');
|
||||
expect(logger.warn.mock.calls[0][0]).toContain('"primary_failure_reason":"jwt expired"');
|
||||
expect(logger.warn.mock.calls[0][0]).toContain('"path":"/api/ask"');
|
||||
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain('invalid signature');
|
||||
});
|
||||
|
||||
it('does not fall back to OpenID JWT for bearer-only reuse requests', () => {
|
||||
it('attributes malformed bearer rejections as structured 401s without an OpenID fallback', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
id: 'malformed-bearer-401',
|
||||
method: 'GET',
|
||||
originalUrl: '/api/banner?access_token=not-logged',
|
||||
headers: { authorization: 'Bearer malformed-token' },
|
||||
_mockStrategies: {
|
||||
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
|
||||
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
|
||||
|
|
@ -601,6 +457,20 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
{ session: false },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event_name: 'jwt_auth_rejected',
|
||||
request_id: 'malformed-bearer-401',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/banner',
|
||||
token_source: 'bearer',
|
||||
reason_category: 'malformed_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain('malformed-token');
|
||||
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain('invalid signature');
|
||||
});
|
||||
|
||||
it('uses OpenID JWT before LibreChat JWT when the OpenID cookie is present', async () => {
|
||||
|
|
@ -658,25 +528,24 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(next).toHaveBeenCalled();
|
||||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] OpenID JWT auth failed; trying fallback',
|
||||
request_id: 'req-mismatch-success',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
reason: 'openid user-id mismatch',
|
||||
status: 401,
|
||||
reason_category: 'principal_mismatch',
|
||||
strategy_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure',
|
||||
request_id: 'req-mismatch-success',
|
||||
auth_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'openid user-id mismatch',
|
||||
reason: 'openid user-id mismatch',
|
||||
primary_failure_reason_category: 'principal_mismatch',
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
|
|
@ -705,24 +574,25 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] OpenID JWT auth failed; trying fallback',
|
||||
request_id: 'req-mismatch-fail',
|
||||
fallback_attempted: true,
|
||||
reason: 'openid user-id mismatch',
|
||||
status: 401,
|
||||
reason_category: 'principal_mismatch',
|
||||
strategy_status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
message: '[requireJwtAuth] Authentication failed after all strategies',
|
||||
request_id: 'req-mismatch-fail',
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
final_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
primary_failure_reason_category: 'principal_mismatch',
|
||||
reason_category: 'missing_or_unrecognized_token',
|
||||
response_status: 401,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const {
|
||||
isEnabled,
|
||||
tenantContextMiddleware,
|
||||
getAuthFailureReason,
|
||||
getAuthFailureErrorName,
|
||||
getAuthFailureReasonCategory,
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware,
|
||||
recordRumProxyRequest,
|
||||
} = require('@librechat/api');
|
||||
|
|
@ -36,6 +34,12 @@ const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.to
|
|||
const refreshCloudFrontCookies =
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next());
|
||||
|
||||
const getAuthTokenSource = (req) => {
|
||||
const authorization = req.headers.authorization;
|
||||
const value = Array.isArray(authorization) ? authorization[0] : authorization;
|
||||
return typeof value === 'string' && /^Bearer\s+/i.test(value) ? 'bearer' : 'none';
|
||||
};
|
||||
|
||||
const getAuthStrategies = (req) => {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
|
||||
|
|
@ -48,6 +52,7 @@ const getAuthStrategies = (req) => {
|
|||
|
||||
return {
|
||||
tokenProvider,
|
||||
tokenSource: getAuthTokenSource(req),
|
||||
openidReuseEnabled,
|
||||
openidJwtAvailable,
|
||||
openIdReuseUserId,
|
||||
|
|
@ -84,57 +89,61 @@ const isOpenIdReuseUser = (strategy, user, openIdReuseUserId) =>
|
|||
* for downstream Mongoose tenant isolation and structured logging.
|
||||
*/
|
||||
const requireJwtAuth = (req, res, next) => {
|
||||
const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } =
|
||||
getAuthStrategies(req);
|
||||
const {
|
||||
tokenProvider,
|
||||
tokenSource,
|
||||
openidReuseEnabled,
|
||||
openidJwtAvailable,
|
||||
openIdReuseUserId,
|
||||
strategies,
|
||||
} = getAuthStrategies(req);
|
||||
const authLogState = {
|
||||
tokenProvider,
|
||||
tokenSource,
|
||||
openidReuseEnabled,
|
||||
openidJwtAvailable,
|
||||
hasOpenIdReuseUserId: openIdReuseUserId != null,
|
||||
};
|
||||
let primaryFailureReason;
|
||||
let primaryFailureErrorName;
|
||||
let primaryFailureReasonCategory;
|
||||
let fallbackAttempted = false;
|
||||
|
||||
const logOpenIdFallbackAttempt = ({ fallbackStrategy, reason, errorName, status }) => {
|
||||
primaryFailureReason = reason;
|
||||
primaryFailureErrorName = errorName;
|
||||
const logOpenIdFallbackAttempt = ({ fallbackStrategy, reasonCategory, status }) => {
|
||||
primaryFailureReasonCategory = reasonCategory;
|
||||
fallbackAttempted = true;
|
||||
const message = '[requireJwtAuth] OpenID JWT auth failed; trying fallback';
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
event_name: 'jwt_auth_fallback_attempt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: fallbackStrategy,
|
||||
fallback_attempted: true,
|
||||
reason,
|
||||
error_name: errorName,
|
||||
status,
|
||||
reason_category: reasonCategory,
|
||||
recovery_classification: 'fallback_attempted',
|
||||
strategy_status: status,
|
||||
});
|
||||
logger.debug(formatAuthLogMessage(message, context), context);
|
||||
logger.debug({ message, ...context });
|
||||
};
|
||||
|
||||
const logAuthenticationFailure = ({ strategy, info, status, err }) => {
|
||||
const message = '[requireJwtAuth] Authentication failed after all strategies';
|
||||
const reasonCategory = getAuthFailureReasonCategory(err, info);
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
event_name: 'jwt_auth_rejected',
|
||||
primary_strategy: strategies[0],
|
||||
fallback_strategy: strategies[1],
|
||||
fallback_attempted: fallbackAttempted,
|
||||
fallback_succeeded: false,
|
||||
attempted_strategies: strategies,
|
||||
final_strategy: strategy,
|
||||
// Surface the primary (openidJwt) failure alongside the final strategy's reason so a
|
||||
// reused-token failure is not misattributed to the HS256 `jwt` fallback's "invalid
|
||||
// algorithm" error, which is the fallback rejecting an RS256 provider token, not the
|
||||
// real reason openidJwt did not authenticate.
|
||||
...(fallbackAttempted && {
|
||||
primary_failure_reason: primaryFailureReason,
|
||||
primary_failure_error_name: primaryFailureErrorName,
|
||||
primary_failure_reason_category: primaryFailureReasonCategory,
|
||||
}),
|
||||
reason: getAuthFailureReason(err, info),
|
||||
error_name: getAuthFailureErrorName(err, info),
|
||||
status: status || 401,
|
||||
reason_category: reasonCategory,
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: status || 401,
|
||||
});
|
||||
const log = fallbackAttempted ? logger.warn : logger.debug;
|
||||
log.call(logger, formatAuthLogMessage(message, context), context);
|
||||
const log =
|
||||
fallbackAttempted || reasonCategory === 'malformed_jwt' ? logger.warn : logger.debug;
|
||||
log.call(logger, { message, ...context });
|
||||
};
|
||||
|
||||
const logFallbackSuccess = (strategy) => {
|
||||
|
|
@ -143,16 +152,16 @@ const requireJwtAuth = (req, res, next) => {
|
|||
}
|
||||
const message = '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure';
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
event_name: 'jwt_auth_recovered',
|
||||
auth_strategy: 'jwt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: primaryFailureReason,
|
||||
reason: primaryFailureReason,
|
||||
error_name: primaryFailureErrorName,
|
||||
primary_failure_reason_category: primaryFailureReasonCategory,
|
||||
recovery_classification: 'fallback_succeeded',
|
||||
});
|
||||
logger.debug(formatAuthLogMessage(message, context), context);
|
||||
logger.debug({ message, ...context });
|
||||
};
|
||||
|
||||
const authenticateWithStrategy = (index) => {
|
||||
|
|
@ -165,8 +174,7 @@ const requireJwtAuth = (req, res, next) => {
|
|||
if (index + 1 < strategies.length) {
|
||||
logOpenIdFallbackAttempt({
|
||||
fallbackStrategy: strategies[index + 1],
|
||||
reason: getAuthFailureReason(err, info),
|
||||
errorName: getAuthFailureErrorName(err, info),
|
||||
reasonCategory: getAuthFailureReasonCategory(err, info),
|
||||
status: status || 401,
|
||||
});
|
||||
return authenticateWithStrategy(index + 1);
|
||||
|
|
@ -180,7 +188,7 @@ const requireJwtAuth = (req, res, next) => {
|
|||
if (index + 1 < strategies.length) {
|
||||
logOpenIdFallbackAttempt({
|
||||
fallbackStrategy: strategies[index + 1],
|
||||
reason: 'openid user-id mismatch',
|
||||
reasonCategory: 'principal_mismatch',
|
||||
status: 401,
|
||||
});
|
||||
return authenticateWithStrategy(index + 1);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
getTenantId,
|
||||
getUserId,
|
||||
getRequestId,
|
||||
getRequestMethod,
|
||||
getRequestPath,
|
||||
SYSTEM_TENANT_ID,
|
||||
logger,
|
||||
} from '@librechat/data-schemas';
|
||||
|
|
@ -12,6 +14,7 @@ import type { ServerRequest } from '~/types/http';
|
|||
// excluded from the public barrel export (index.ts).
|
||||
import {
|
||||
tenantContextMiddleware,
|
||||
requestContextMiddleware,
|
||||
restoreTenantContextFromReq,
|
||||
resolveRequestTenantId,
|
||||
_resetTenantMiddlewareStrictCache,
|
||||
|
|
@ -76,6 +79,103 @@ function runMiddlewareContext(
|
|||
});
|
||||
}
|
||||
|
||||
function runRequestContext(req: Parameters<typeof requestContextMiddleware>[0]): Promise<{
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
requestId?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
requestContextMiddleware(req, mockRes(), async () => {
|
||||
await new Promise((nextTick) => setImmediate(nextTick));
|
||||
resolve({
|
||||
tenantId: getTenantId(),
|
||||
userId: getUserId(),
|
||||
requestId: getRequestId(),
|
||||
method: getRequestMethod(),
|
||||
path: getRequestPath(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('requestContextMiddleware', () => {
|
||||
it('generates a safe request ID when no trusted correlation ID is available', async () => {
|
||||
const req: Parameters<typeof requestContextMiddleware>[0] = {
|
||||
headers: {
|
||||
'x-request-id': `${'a'.repeat(24)}.${'b'.repeat(24)}.${'c'.repeat(24)}`,
|
||||
},
|
||||
method: 'GET',
|
||||
originalUrl: '/api/banner',
|
||||
};
|
||||
|
||||
const context = await runRequestContext(req);
|
||||
|
||||
expect(context.requestId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(req.requestId).toBe(context.requestId);
|
||||
});
|
||||
|
||||
it('does not trust tenant or user identity before authentication', async () => {
|
||||
const req: Parameters<typeof requestContextMiddleware>[0] = {
|
||||
headers: { 'x-request-id': 'pre-auth-request' },
|
||||
method: 'GET',
|
||||
originalUrl: '/api/auth/me',
|
||||
tenantId: 'untrusted-tenant',
|
||||
user: { id: 'untrusted-user', tenantId: 'untrusted-tenant' },
|
||||
};
|
||||
|
||||
const context = await runRequestContext(req);
|
||||
|
||||
expect(context).toEqual({
|
||||
tenantId: undefined,
|
||||
userId: undefined,
|
||||
requestId: 'pre-auth-request',
|
||||
method: 'GET',
|
||||
path: '/api/auth',
|
||||
});
|
||||
expect(req.requestId).toBe('pre-auth-request');
|
||||
});
|
||||
|
||||
it('keeps malformed-auth and parallel page requests independently attributable', async () => {
|
||||
const malformedAuthRequest = {
|
||||
headers: {
|
||||
authorization: 'Bearer malformed-token',
|
||||
'x-request-id': 'auth-401-request',
|
||||
},
|
||||
method: 'GET',
|
||||
originalUrl: '/api/auth/me?access_token=not-logged',
|
||||
};
|
||||
const pageRequest = {
|
||||
headers: { 'x-request-id': 'page-request' },
|
||||
method: 'GET',
|
||||
originalUrl: '/api/banner?access_token=not-logged',
|
||||
};
|
||||
|
||||
const [authContext, pageContext] = await Promise.all([
|
||||
runRequestContext(malformedAuthRequest),
|
||||
runRequestContext(pageRequest),
|
||||
]);
|
||||
|
||||
expect(authContext).toEqual({
|
||||
tenantId: undefined,
|
||||
userId: undefined,
|
||||
requestId: 'auth-401-request',
|
||||
method: 'GET',
|
||||
path: '/api/auth',
|
||||
});
|
||||
expect(pageContext).toEqual({
|
||||
tenantId: undefined,
|
||||
userId: undefined,
|
||||
requestId: 'page-request',
|
||||
method: 'GET',
|
||||
path: '/api/banner',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tenantContextMiddleware', () => {
|
||||
afterEach(() => {
|
||||
_resetTenantMiddlewareStrictCache();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { AuthLogRequest, AuthLogState } from './auth';
|
||||
import {
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
buildSafeRequestLogContext,
|
||||
buildTenantIsolationErrorLogContext,
|
||||
getAuthFailureErrorName,
|
||||
getAuthFailureReason,
|
||||
getAuthFailureReasonCategory,
|
||||
} from './auth';
|
||||
import type { AuthLogRequest, AuthLogState } from './auth';
|
||||
|
||||
function createRequest(overrides: Partial<AuthLogRequest> = {}): AuthLogRequest {
|
||||
return {
|
||||
|
|
@ -19,6 +21,7 @@ function createRequest(overrides: Partial<AuthLogRequest> = {}): AuthLogRequest
|
|||
function createAuthState(overrides: Partial<AuthLogState> = {}): AuthLogState {
|
||||
return {
|
||||
tokenProvider: 'openid',
|
||||
tokenSource: 'bearer',
|
||||
openidReuseEnabled: true,
|
||||
openidJwtAvailable: true,
|
||||
hasOpenIdReuseUserId: true,
|
||||
|
|
@ -36,29 +39,32 @@ describe('auth middleware logging helpers', () => {
|
|||
}),
|
||||
createAuthState(),
|
||||
{
|
||||
event_name: 'jwt_auth_rejected',
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
reason_category: 'expired_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
},
|
||||
);
|
||||
|
||||
expect(log).toEqual({
|
||||
request_id: 'request-id',
|
||||
method: 'GET',
|
||||
path: '/api/ask',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/ask',
|
||||
token_provider: 'openid',
|
||||
token_source: 'bearer',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
event_name: 'jwt_auth_rejected',
|
||||
reason_category: 'expired_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
});
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
});
|
||||
|
|
@ -72,6 +78,7 @@ describe('auth middleware logging helpers', () => {
|
|||
}),
|
||||
createAuthState({
|
||||
tokenProvider: null,
|
||||
tokenSource: null,
|
||||
openidReuseEnabled: false,
|
||||
openidJwtAvailable: false,
|
||||
hasOpenIdReuseUserId: false,
|
||||
|
|
@ -80,14 +87,47 @@ describe('auth middleware logging helpers', () => {
|
|||
|
||||
expect(log).toEqual({
|
||||
request_id: 'header-request-id',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
openid_reuse_enabled: false,
|
||||
openid_jwt_available: false,
|
||||
has_openid_reuse_user_id: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
`${'a'.repeat(24)}.${'b'.repeat(24)}.${'c'.repeat(24)}`,
|
||||
'header..signature',
|
||||
'header.payload.',
|
||||
'header..initialization-vector.ciphertext.authentication-tag',
|
||||
'a'.repeat(129),
|
||||
])('drops credential-shaped or oversized request ID %s', (requestId) => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({ headers: { 'x-request-id': requestId } }),
|
||||
createAuthState(),
|
||||
);
|
||||
|
||||
expect(log.request_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the next valid correlation candidate after rejecting an unsafe request ID', () => {
|
||||
const context = buildSafeRequestLogContext(
|
||||
createRequest({
|
||||
requestId: `${'a'.repeat(24)}.${'b'.repeat(24)}.${'c'.repeat(24)}`,
|
||||
headers: { 'x-request-id': 'safe-header-request' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context.request_id).toBe('safe-header-request');
|
||||
});
|
||||
|
||||
it('normalizes known request methods and buckets unknown methods', () => {
|
||||
expect(buildSafeRequestLogContext(createRequest({ method: 'get' })).request_method).toBe('GET');
|
||||
expect(
|
||||
buildSafeRequestLogContext(createRequest({ method: 'CUSTOM_METHOD' })).request_method,
|
||||
).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('buckets unknown token providers to keep auth logs low-cardinality', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest(),
|
||||
|
|
@ -99,6 +139,15 @@ describe('auth middleware logging helpers', () => {
|
|||
expect(log.token_provider).toBe('other');
|
||||
});
|
||||
|
||||
it('buckets unknown token sources to keep auth logs low-cardinality', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest(),
|
||||
createAuthState({ tokenSource: 'attacker-controlled-source' }),
|
||||
);
|
||||
|
||||
expect(log.token_source).toBe('other');
|
||||
});
|
||||
|
||||
it('prefers route buckets over concrete dynamic request paths', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({
|
||||
|
|
@ -109,7 +158,7 @@ describe('auth middleware logging helpers', () => {
|
|||
createAuthState(),
|
||||
);
|
||||
|
||||
expect(log.path).toBe('/api/messages');
|
||||
expect(log.request_path).toBe('/api/messages');
|
||||
expect(JSON.stringify(log)).not.toContain('conversation-123');
|
||||
expect(JSON.stringify(log)).not.toContain('message-456');
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
|
|
@ -125,48 +174,127 @@ describe('auth middleware logging helpers', () => {
|
|||
createAuthState(),
|
||||
);
|
||||
|
||||
expect(log.path).toBe('/api/share/link/:conversationId');
|
||||
expect(log.request_path).toBe('/api/share/link/:conversationId');
|
||||
});
|
||||
|
||||
it('drops unsupported extra values and keeps safe arrays primitive', () => {
|
||||
it('buckets the original URL when Express leaves an unmounted route template', () => {
|
||||
const context = buildSafeRequestLogContext(
|
||||
createRequest({
|
||||
baseUrl: '',
|
||||
path: '/conversation-123',
|
||||
originalUrl: '/api/convos/conversation-123?access_token=secret-token',
|
||||
route: { path: '/:id' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context.request_path).toBe('/api/convos');
|
||||
expect(JSON.stringify(context)).not.toContain('conversation-123');
|
||||
expect(JSON.stringify(context)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('drops unsupported and sensitive extra values while keeping allowed fields', () => {
|
||||
const log = buildSafeAuthLogContext(createRequest({ id: 'request-id' }), createAuthState(), {
|
||||
attempted_strategies: ['openidJwt', '', { strategy: 'jwt' }, 'jwt'],
|
||||
fallback_attempted: true,
|
||||
path: { unsafe: true },
|
||||
request_id: { unsafe: true },
|
||||
status: Number.NaN,
|
||||
response_status: Number.NaN,
|
||||
unsafe_object: { token: 'secret-token' },
|
||||
reason: ' jwt expired ',
|
||||
});
|
||||
|
||||
expect(log).toEqual({
|
||||
request_id: 'request-id',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
token_provider: 'openid',
|
||||
token_source: 'bearer',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
});
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('formats auth log messages with serialized safe context for stdout collectors', () => {
|
||||
const log = buildSafeAuthLogContext(createRequest({ id: 'request-id' }), createAuthState(), {
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(
|
||||
formatAuthLogMessage('[requireJwtAuth] OpenID JWT auth failed; trying fallback', log),
|
||||
).toBe(
|
||||
'[requireJwtAuth] OpenID JWT auth failed; trying fallback {"fallback_attempted":true,"reason":"jwt expired","error_name":"TokenExpiredError","status":401,"request_id":"request-id","method":"GET","path":"/api/messages","token_provider":"openid","openid_reuse_enabled":true,"openid_jwt_available":true,"has_openid_reuse_user_id":true}',
|
||||
it('builds request context without raw query strings', () => {
|
||||
const context = buildSafeRequestLogContext(
|
||||
createRequest({
|
||||
id: 'request-id',
|
||||
path: undefined,
|
||||
originalUrl: '/api/convos/conversation-123?access_token=secret-token',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
request_id: 'request-id',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/convos',
|
||||
});
|
||||
expect(JSON.stringify(context)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('builds a safe, joinable context for tenant-isolation errors', () => {
|
||||
const context = buildTenantIsolationErrorLogContext(
|
||||
createRequest({
|
||||
id: 'request-id',
|
||||
path: undefined,
|
||||
originalUrl: '/api/banner?access_token=secret-token',
|
||||
}),
|
||||
new Error('[TenantIsolation] Query attempted without tenant context in strict mode'),
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
event_name: 'tenant_isolation_error',
|
||||
error_category: 'tenant_isolation',
|
||||
error_signature: 'missing_query_context',
|
||||
response_status: 500,
|
||||
request_id: 'request-id',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/banner',
|
||||
});
|
||||
expect(JSON.stringify(context)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'[TenantIsolation] Query attempted without tenant context in strict mode',
|
||||
'missing_query_context',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] Aggregate attempted without tenant context in strict mode',
|
||||
'missing_aggregate_context',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] Save attempted without tenant context in strict mode',
|
||||
'missing_save_context',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] insertMany attempted without tenant context in strict mode',
|
||||
'missing_insert_many_context',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] bulkWrite on Message attempted without tenant context in strict mode',
|
||||
'missing_bulk_write_context',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] Unknown bulkWrite operation type in strict mode — refusing to pass through without tenant injection',
|
||||
'unsupported_bulk_write_operation',
|
||||
],
|
||||
['[TenantIsolation] Cross-tenant tenantId mutation is not allowed', 'cross_tenant_mutation'],
|
||||
[
|
||||
'[TenantIsolation] Document tenantId does not match current tenant context',
|
||||
'tenant_mismatch',
|
||||
],
|
||||
[
|
||||
'[TenantIsolation] Modifying tenantId via replacement is not allowed',
|
||||
'replacement_tenant_mutation',
|
||||
],
|
||||
])('classifies known tenant-isolation error %s', (message, errorSignature) => {
|
||||
const context = buildTenantIsolationErrorLogContext(createRequest(), new Error(message));
|
||||
|
||||
expect(context?.error_signature).toBe(errorSignature);
|
||||
});
|
||||
|
||||
it('prefers Passport info fields for auth failure reason and error name', () => {
|
||||
|
|
@ -175,6 +303,7 @@ describe('auth middleware logging helpers', () => {
|
|||
|
||||
expect(getAuthFailureReason(err, info)).toBe('jwt expired');
|
||||
expect(getAuthFailureErrorName(err, info)).toBe('TokenExpiredError');
|
||||
expect(getAuthFailureReasonCategory(err, info)).toBe('expired_jwt');
|
||||
});
|
||||
|
||||
it('falls back to Error fields when Passport info is absent', () => {
|
||||
|
|
@ -182,6 +311,7 @@ describe('auth middleware logging helpers', () => {
|
|||
|
||||
expect(getAuthFailureReason(err, undefined)).toBe('invalid signature');
|
||||
expect(getAuthFailureErrorName(err, undefined)).toBe('JsonWebTokenError');
|
||||
expect(getAuthFailureReasonCategory(err, undefined)).toBe('malformed_jwt');
|
||||
});
|
||||
|
||||
it('does not throw when Passport failure objects expose throwing getters', () => {
|
||||
|
|
@ -202,5 +332,6 @@ describe('auth middleware logging helpers', () => {
|
|||
|
||||
expect(getAuthFailureReason(err, info)).toBe('invalid signature');
|
||||
expect(getAuthFailureErrorName(err, info)).toBe('JsonWebTokenError');
|
||||
expect(getAuthFailureReasonCategory(err, info)).toBe('malformed_jwt');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,36 @@ type AuthLogValue = string | number | boolean | readonly string[];
|
|||
type AuthLogHeaderValue = string | string[] | undefined;
|
||||
type AuthRoutePath = string | RegExp | readonly (string | RegExp)[];
|
||||
|
||||
const COMPACT_JWT_VALUE =
|
||||
/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*){2}$|^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*){4}$/;
|
||||
const MAX_REQUEST_ID_LENGTH = 128;
|
||||
const SAFE_REQUEST_METHODS = new Set([
|
||||
'CONNECT',
|
||||
'DELETE',
|
||||
'GET',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
'TRACE',
|
||||
]);
|
||||
const AUTH_LOG_EXTRA_KEYS = new Set([
|
||||
'event_name',
|
||||
'auth_strategy',
|
||||
'primary_strategy',
|
||||
'fallback_strategy',
|
||||
'fallback_attempted',
|
||||
'fallback_succeeded',
|
||||
'attempted_strategies',
|
||||
'final_strategy',
|
||||
'primary_failure_reason_category',
|
||||
'reason_category',
|
||||
'recovery_classification',
|
||||
'response_status',
|
||||
'strategy_status',
|
||||
]);
|
||||
|
||||
export type AuthLogRequest = {
|
||||
headers?: Record<string, AuthLogHeaderValue>;
|
||||
method?: string;
|
||||
|
|
@ -23,12 +53,26 @@ export type AuthLogRequest = {
|
|||
|
||||
export type AuthLogState = {
|
||||
tokenProvider?: string | null;
|
||||
tokenSource?: string | null;
|
||||
openidReuseEnabled: boolean;
|
||||
openidJwtAvailable: boolean;
|
||||
hasOpenIdReuseUserId: boolean;
|
||||
};
|
||||
|
||||
export type AuthLogContext = Record<string, AuthLogValue>;
|
||||
export type RequestLogContext = {
|
||||
request_id?: string;
|
||||
request_method?: string;
|
||||
request_path?: string;
|
||||
};
|
||||
|
||||
export type AuthLogContext = RequestLogContext & Record<string, AuthLogValue>;
|
||||
|
||||
export type AuthFailureReasonCategory =
|
||||
| 'expired_jwt'
|
||||
| 'malformed_jwt'
|
||||
| 'principal_mismatch'
|
||||
| 'missing_or_unrecognized_token'
|
||||
| 'authentication_error';
|
||||
|
||||
function normalizeAuthLogValue(value: unknown): string | undefined {
|
||||
if (value == null) {
|
||||
|
|
@ -85,12 +129,34 @@ function normalizeAuthLogContextValue(value: unknown): AuthLogValue | undefined
|
|||
}
|
||||
|
||||
function getRequestId(req: AuthLogRequest): string | undefined {
|
||||
return (
|
||||
normalizeAuthLogValue(req.requestId) ??
|
||||
normalizeAuthLogValue(req.id) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-request-id']) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-correlation-id'])
|
||||
);
|
||||
const candidates = [
|
||||
req.requestId,
|
||||
req.id,
|
||||
req.headers?.['x-request-id'],
|
||||
req.headers?.['x-correlation-id'],
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const requestId = normalizeAuthLogValue(candidate);
|
||||
if (
|
||||
requestId &&
|
||||
requestId.length <= MAX_REQUEST_ID_LENGTH &&
|
||||
!COMPACT_JWT_VALUE.test(requestId) &&
|
||||
/^[A-Za-z0-9_.:-]+$/.test(requestId)
|
||||
) {
|
||||
return requestId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRequestMethod(method: unknown): string | undefined {
|
||||
const normalized = normalizeAuthLogValue(method)?.toUpperCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return SAFE_REQUEST_METHODS.has(normalized) ? normalized : 'OTHER';
|
||||
}
|
||||
|
||||
function normalizeRoutePath(path: AuthRoutePath | undefined): string | undefined {
|
||||
|
|
@ -138,19 +204,34 @@ function bucketConcretePath(path: string | undefined): string | undefined {
|
|||
}
|
||||
|
||||
function getRequestPath(req: AuthLogRequest): string | undefined {
|
||||
const baseUrl = normalizeAuthLogValue(req.baseUrl);
|
||||
const baseUrl = bucketConcretePath(normalizeAuthLogValue(req.baseUrl));
|
||||
const routePath = normalizeRoutePath(req.route?.path);
|
||||
if (routePath) {
|
||||
if (routePath && baseUrl) {
|
||||
return joinRoutePath(baseUrl, routePath);
|
||||
}
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const path = normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
|
||||
const path =
|
||||
normalizeAuthLogValue(req.originalUrl) ??
|
||||
normalizeAuthLogValue(req.path) ??
|
||||
normalizeAuthLogValue(req.url);
|
||||
return bucketConcretePath(path);
|
||||
}
|
||||
|
||||
export function buildSafeRequestLogContext(req: AuthLogRequest): RequestLogContext {
|
||||
const requestId = getRequestId(req);
|
||||
const requestMethod = getRequestMethod(req.method);
|
||||
const requestPath = getRequestPath(req);
|
||||
|
||||
return {
|
||||
...(requestId && { request_id: requestId }),
|
||||
...(requestMethod && { request_method: requestMethod }),
|
||||
...(requestPath && { request_path: requestPath }),
|
||||
};
|
||||
}
|
||||
|
||||
function getAuthFailureField(source: unknown, field: keyof AuthFailureLike): unknown {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
|
|
@ -168,10 +249,10 @@ function getAuthFailureField(source: unknown, field: keyof AuthFailureLike): unk
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function compactAuthLogContext(log: Record<string, unknown>): AuthLogContext {
|
||||
function compactAuthLogContext(log: object): AuthLogContext {
|
||||
const compacted: Partial<AuthLogContext> = {};
|
||||
for (const key of Object.keys(log)) {
|
||||
const value = normalizeAuthLogContextValue(log[key]);
|
||||
for (const [key, rawValue] of Object.entries(log)) {
|
||||
const value = normalizeAuthLogContextValue(rawValue);
|
||||
if (value !== undefined) {
|
||||
Object.assign(compacted, { [key]: value });
|
||||
}
|
||||
|
|
@ -179,6 +260,10 @@ function compactAuthLogContext(log: Record<string, unknown>): AuthLogContext {
|
|||
return compacted as AuthLogContext;
|
||||
}
|
||||
|
||||
function selectAuthLogExtra(extra: object): object {
|
||||
return Object.fromEntries(Object.entries(extra).filter(([key]) => AUTH_LOG_EXTRA_KEYS.has(key)));
|
||||
}
|
||||
|
||||
export function getAuthFailureReason(
|
||||
err: unknown,
|
||||
info: unknown,
|
||||
|
|
@ -198,6 +283,39 @@ export function getAuthFailureErrorName(err: unknown, info: unknown): string | u
|
|||
);
|
||||
}
|
||||
|
||||
export function getAuthFailureReasonCategory(
|
||||
err: unknown,
|
||||
info: unknown,
|
||||
): AuthFailureReasonCategory {
|
||||
const reason = getAuthFailureReason(err, info).toLowerCase();
|
||||
const errorName = getAuthFailureErrorName(err, info)?.toLowerCase();
|
||||
|
||||
if (reason.includes('expired') || errorName === 'tokenexpirederror') {
|
||||
return 'expired_jwt';
|
||||
}
|
||||
|
||||
if (reason.includes('user-id mismatch') || reason.includes('principal mismatch')) {
|
||||
return 'principal_mismatch';
|
||||
}
|
||||
|
||||
if (
|
||||
errorName === 'jsonwebtokenerror' ||
|
||||
reason.includes('jwt malformed') ||
|
||||
reason.includes('invalid signature') ||
|
||||
reason.includes('invalid algorithm') ||
|
||||
reason.includes('invalid token') ||
|
||||
reason.includes('invalid key')
|
||||
) {
|
||||
return 'malformed_jwt';
|
||||
}
|
||||
|
||||
if (reason === 'unauthorized' || reason.includes('no auth token')) {
|
||||
return 'missing_or_unrecognized_token';
|
||||
}
|
||||
|
||||
return 'authentication_error';
|
||||
}
|
||||
|
||||
function getSafeTokenProvider(tokenProvider: unknown): string | undefined {
|
||||
const normalized = normalizeAuthLogValue(tokenProvider);
|
||||
if (!normalized) {
|
||||
|
|
@ -206,23 +324,86 @@ function getSafeTokenProvider(tokenProvider: unknown): string | undefined {
|
|||
return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
|
||||
}
|
||||
|
||||
function getSafeTokenSource(tokenSource: unknown): string | undefined {
|
||||
const normalized = normalizeAuthLogValue(tokenSource);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ['bearer', 'none'].includes(normalized) ? normalized : 'other';
|
||||
}
|
||||
|
||||
export function buildSafeAuthLogContext(
|
||||
req: AuthLogRequest,
|
||||
authState: AuthLogState,
|
||||
extra: Record<string, unknown> = {},
|
||||
extra: object = {},
|
||||
): AuthLogContext {
|
||||
return compactAuthLogContext({
|
||||
...extra,
|
||||
request_id: getRequestId(req),
|
||||
method: normalizeAuthLogValue(req.method),
|
||||
path: getRequestPath(req),
|
||||
token_provider: getSafeTokenProvider(authState.tokenProvider),
|
||||
openid_reuse_enabled: authState.openidReuseEnabled,
|
||||
openid_jwt_available: authState.openidJwtAvailable,
|
||||
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
|
||||
});
|
||||
return {
|
||||
...compactAuthLogContext({
|
||||
...selectAuthLogExtra(extra),
|
||||
token_provider: getSafeTokenProvider(authState.tokenProvider),
|
||||
token_source: getSafeTokenSource(authState.tokenSource),
|
||||
openid_reuse_enabled: authState.openidReuseEnabled,
|
||||
openid_jwt_available: authState.openidJwtAvailable,
|
||||
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
|
||||
}),
|
||||
...buildSafeRequestLogContext(req),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Pass `{ message, ...context }` to the logger so structured fields
|
||||
* survive JSON message truncation. Retained for package API compatibility only.
|
||||
*/
|
||||
export function formatAuthLogMessage(message: string, context: AuthLogContext): string {
|
||||
return `${message} ${JSON.stringify(context)}`;
|
||||
}
|
||||
|
||||
function getTenantIsolationSignature(message: string): string {
|
||||
if (message.includes('bulkWrite on') && message.includes('without tenant context')) {
|
||||
return 'missing_bulk_write_context';
|
||||
}
|
||||
if (message.includes('Unknown bulkWrite operation type')) {
|
||||
return 'unsupported_bulk_write_operation';
|
||||
}
|
||||
if (message.includes('Query attempted without tenant context')) {
|
||||
return 'missing_query_context';
|
||||
}
|
||||
if (message.includes('Aggregate attempted without tenant context')) {
|
||||
return 'missing_aggregate_context';
|
||||
}
|
||||
if (message.includes('Save attempted without tenant context')) {
|
||||
return 'missing_save_context';
|
||||
}
|
||||
if (message.includes('insertMany attempted without tenant context')) {
|
||||
return 'missing_insert_many_context';
|
||||
}
|
||||
if (message.includes('Cross-tenant')) {
|
||||
return 'cross_tenant_mutation';
|
||||
}
|
||||
if (message.includes('does not match current tenant context')) {
|
||||
return 'tenant_mismatch';
|
||||
}
|
||||
if (message.includes('Modifying tenantId via replacement')) {
|
||||
return 'replacement_tenant_mutation';
|
||||
}
|
||||
return 'tenant_isolation_error';
|
||||
}
|
||||
|
||||
export function buildTenantIsolationErrorLogContext(
|
||||
req: AuthLogRequest,
|
||||
err: unknown,
|
||||
): AuthLogContext | undefined {
|
||||
const message = normalizeAuthLogValue(getAuthFailureField(err, 'message'));
|
||||
if (!message?.startsWith('[TenantIsolation]')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
event_name: 'tenant_isolation_error',
|
||||
error_category: 'tenant_isolation',
|
||||
error_signature: getTenantIsolationSignature(message),
|
||||
response_status: 500,
|
||||
...buildSafeRequestLogContext(req),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorController } from './error';
|
||||
import { logger, tenantStorage } from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ValidationError, MongoServerError, CustomError } from '~/types';
|
||||
import { ErrorController } from './error';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -231,6 +231,69 @@ describe('ErrorController', () => {
|
|||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', genericError);
|
||||
});
|
||||
|
||||
it('emits a structured, joinable event for tenant-isolation errors', () => {
|
||||
Object.assign(mockReq, {
|
||||
id: 'request-123',
|
||||
method: 'GET',
|
||||
originalUrl: '/api/banner?access_token=secret-token',
|
||||
});
|
||||
const tenantError = new Error(
|
||||
'[TenantIsolation] Query attempted without tenant context in strict mode',
|
||||
);
|
||||
|
||||
ErrorController(tenantError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
message: 'Tenant-isolation request failed',
|
||||
event_name: 'tenant_isolation_error',
|
||||
error_category: 'tenant_isolation',
|
||||
error_signature: 'missing_query_context',
|
||||
response_status: 500,
|
||||
request_id: 'request-123',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/banner',
|
||||
});
|
||||
expect(JSON.stringify((logger.error as jest.Mock).mock.calls)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('preserves the captured router mount for tenant-isolation errors', () => {
|
||||
Object.assign(mockReq, {
|
||||
id: 'request-123',
|
||||
method: 'GET',
|
||||
baseUrl: '/api',
|
||||
route: { path: '/:id' },
|
||||
originalUrl: '/api/convos/conversation-123?access_token=secret-token',
|
||||
});
|
||||
const tenantError = new Error(
|
||||
'[TenantIsolation] Query attempted without tenant context in strict mode',
|
||||
);
|
||||
|
||||
tenantStorage.run(
|
||||
{
|
||||
requestId: 'request-123',
|
||||
requestMethod: 'GET',
|
||||
requestPath: '/api/convos',
|
||||
},
|
||||
() => ErrorController(tenantError, mockReq, mockRes, mockNext),
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
message: 'Tenant-isolation request failed',
|
||||
event_name: 'tenant_isolation_error',
|
||||
error_category: 'tenant_isolation',
|
||||
error_signature: 'missing_query_context',
|
||||
response_status: 500,
|
||||
request_id: 'request-123',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/convos',
|
||||
});
|
||||
expect(JSON.stringify((logger.error as jest.Mock).mock.calls)).not.toContain(
|
||||
'conversation-123',
|
||||
);
|
||||
expect(JSON.stringify((logger.error as jest.Mock).mock.calls)).not.toContain('secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch block handling', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { logger, tenantStorage } from '@librechat/data-schemas';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { MongoServerError, ValidationError, CustomError } from '~/types';
|
||||
import { buildTenantIsolationErrorLogContext } from './auth';
|
||||
|
||||
const handleDuplicateKeyError = (err: MongoServerError, res: Response) => {
|
||||
logger.warn('Duplicate key error: ' + (err.errmsg || err.message));
|
||||
|
|
@ -74,7 +75,19 @@ export const ErrorController = (
|
|||
return res.status(error.statusCode).send(error.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
const tenantIsolationContext = buildTenantIsolationErrorLogContext(req, err);
|
||||
if (tenantIsolationContext) {
|
||||
const { requestId, requestMethod, requestPath } = tenantStorage.getStore() ?? {};
|
||||
logger.error({
|
||||
message: 'Tenant-isolation request failed',
|
||||
...tenantIsolationContext,
|
||||
...(requestId && { request_id: requestId }),
|
||||
...(requestMethod && { request_method: requestMethod }),
|
||||
...(requestPath && { request_path: requestPath }),
|
||||
});
|
||||
} else {
|
||||
logger.error('ErrorController => error', err);
|
||||
}
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (processingError) {
|
||||
logger.error('ErrorController => processing error', processingError);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export * from './json';
|
|||
export * from './capabilities';
|
||||
export * from './auth';
|
||||
export {
|
||||
requestContextMiddleware,
|
||||
tenantContextMiddleware,
|
||||
restoreTenantContextFromReq,
|
||||
resolveRequestTenantId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getTenantId, getRequestId, logger } from '@librechat/data-schemas';
|
||||
import { preAuthTenantMiddleware } from './preAuthTenant';
|
||||
import { getTenantId, getUserId, getRequestId, logger } from '@librechat/data-schemas';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { preAuthTenantMiddleware } from './preAuthTenant';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
|
|
@ -13,7 +13,13 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
}));
|
||||
|
||||
describe('preAuthTenantMiddleware', () => {
|
||||
let req: { headers: Record<string, string | string[] | undefined>; ip?: string; path?: string };
|
||||
let req: {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
ip?: string;
|
||||
path?: string;
|
||||
tenantId?: string;
|
||||
user?: { id: string; tenantId: string };
|
||||
};
|
||||
let res: Partial<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -65,6 +71,19 @@ describe('preAuthTenantMiddleware', () => {
|
|||
expect(capturedRequestId).toBe('req-preauth');
|
||||
});
|
||||
|
||||
it('does not inherit request identity before authentication', () => {
|
||||
req.tenantId = 'untrusted-tenant';
|
||||
req.user = { id: 'untrusted-user', tenantId: 'untrusted-tenant' };
|
||||
let capturedContext: { tenantId?: string; userId?: string } = {};
|
||||
const capturedNext: NextFunction = () => {
|
||||
capturedContext = { tenantId: getTenantId(), userId: getUserId() };
|
||||
};
|
||||
|
||||
preAuthTenantMiddleware(req as Request, res as Response, capturedNext);
|
||||
|
||||
expect(capturedContext).toEqual({ tenantId: undefined, userId: undefined });
|
||||
});
|
||||
|
||||
it('ignores __SYSTEM__ sentinel and logs warning', () => {
|
||||
req.headers = { 'x-tenant-id': '__SYSTEM__' };
|
||||
req.ip = '10.0.0.1';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { buildTenantContext, runWithTenantContext } from './tenant';
|
||||
import { buildRequestContext, runWithTenantContext } from './tenant';
|
||||
|
||||
/**
|
||||
* Pre-authentication tenant context middleware for unauthenticated routes.
|
||||
|
|
@ -35,7 +35,7 @@ const VALID_TENANT_ID = /^[-a-zA-Z0-9_.]+$/;
|
|||
|
||||
export function preAuthTenantMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const raw = req.headers['x-tenant-id'];
|
||||
const requestContext = buildTenantContext({ headers: req.headers });
|
||||
const requestContext = buildRequestContext(req);
|
||||
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
runWithTenantContext(requestContext, next);
|
||||
|
|
@ -72,5 +72,5 @@ export function preAuthTenantMiddleware(req: Request, res: Response, next: NextF
|
|||
return;
|
||||
}
|
||||
|
||||
runWithTenantContext(buildTenantContext({ headers: req.headers }, tenantId), next);
|
||||
runWithTenantContext({ ...requestContext, tenantId }, next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { isMainThread } from 'worker_threads';
|
||||
import { tenantStorage, logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas';
|
||||
import type { TenantContext } from '@librechat/data-schemas';
|
||||
import type { Response, NextFunction } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
import { buildSafeRequestLogContext } from './auth';
|
||||
|
||||
type ContextUser = {
|
||||
tenantId?: string;
|
||||
|
|
@ -11,15 +13,22 @@ type ContextUser = {
|
|||
_id?: { toString: () => string };
|
||||
} | null;
|
||||
|
||||
type ContextRequest = {
|
||||
export type ContextRequest = {
|
||||
headers: ServerRequest['headers'];
|
||||
tenantId?: string;
|
||||
user?: ContextUser;
|
||||
id?: string;
|
||||
requestId?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
baseUrl?: string;
|
||||
route?: {
|
||||
path?: string | RegExp | readonly (string | RegExp)[];
|
||||
};
|
||||
};
|
||||
|
||||
const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id'] as const;
|
||||
const SYSTEM_TENANT_REJECTION_MESSAGE = 'System tenant is not allowed for request-scoped routes';
|
||||
|
||||
let _checkedThread = false;
|
||||
|
|
@ -40,30 +49,18 @@ function normalizeContextValue(value?: string): string | undefined {
|
|||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function getHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return normalizeContextValue(Array.isArray(value) ? value[0] : value);
|
||||
}
|
||||
|
||||
function getRequestId(req: ContextRequest): string | undefined {
|
||||
const requestId = normalizeContextValue(req.requestId) ?? normalizeContextValue(req.id);
|
||||
if (requestId) {
|
||||
return requestId;
|
||||
}
|
||||
for (const header of REQUEST_ID_HEADERS) {
|
||||
const value = getHeaderValue(req.headers[header]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getUserId(user: ContextUser): string | undefined {
|
||||
return normalizeContextValue(user?.id) ?? normalizeContextValue(user?._id?.toString());
|
||||
}
|
||||
|
||||
function hasTenantContext(context: TenantContext): boolean {
|
||||
return Boolean(context.tenantId || context.userId || context.requestId);
|
||||
return Boolean(
|
||||
context.tenantId ||
|
||||
context.userId ||
|
||||
context.requestId ||
|
||||
context.requestMethod ||
|
||||
context.requestPath,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTenantContext(
|
||||
|
|
@ -71,12 +68,39 @@ export function buildTenantContext(
|
|||
tenantId: string | undefined = req.tenantId ?? req.user?.tenantId,
|
||||
): TenantContext {
|
||||
return {
|
||||
...buildRequestContext(req),
|
||||
tenantId: normalizeContextValue(tenantId),
|
||||
userId: getUserId(req.user ?? null),
|
||||
requestId: getRequestId(req),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestContext(req: ContextRequest): TenantContext {
|
||||
const requestContext = buildSafeRequestLogContext(req);
|
||||
|
||||
return {
|
||||
requestId: requestContext.request_id,
|
||||
requestMethod: requestContext.request_method,
|
||||
requestPath: requestContext.request_path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes safe, request-level correlation before authentication. It carries
|
||||
* no tenant or user identity, so strict tenant isolation remains fail-closed.
|
||||
*/
|
||||
export function requestContextMiddleware(
|
||||
req: ContextRequest,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const context = buildRequestContext(req);
|
||||
if (!context.requestId) {
|
||||
context.requestId = randomUUID();
|
||||
}
|
||||
req.requestId = context.requestId;
|
||||
runWithTenantContext(context, next);
|
||||
}
|
||||
|
||||
export function runWithTenantContext(context: TenantContext, next: NextFunction): void {
|
||||
if (!hasTenantContext(context)) {
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import winston from 'winston';
|
||||
import { debugTraverse, redactFormat, redactMessage } from './parsers';
|
||||
import { debugTraverse, jsonTruncateFormat, redactFormat, redactMessage } from './parsers';
|
||||
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
|
|
@ -319,4 +319,75 @@ describe('debugTraverse request context', () => {
|
|||
expect(out).not.toMatch(/tenantId:/);
|
||||
expect(out).toContain('userId');
|
||||
});
|
||||
|
||||
it('preserves structured auth metadata for non-JSON warning output', () => {
|
||||
const out = runFormatter(
|
||||
buildInfo('warn', {
|
||||
event_name: 'jwt_auth_rejected',
|
||||
request_id: 'request-123',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
token_source: 'bearer',
|
||||
reason_category: 'malformed_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(out).toContain('"event_name":"jwt_auth_rejected"');
|
||||
expect(out).toContain('"request_id":"request-123"');
|
||||
expect(out).toContain('"reason_category":"malformed_jwt"');
|
||||
expect(out).toContain('"response_status":401');
|
||||
});
|
||||
|
||||
it('does not render application metadata as request correlation context', () => {
|
||||
const out = runFormatter(
|
||||
buildInfo('warn', {
|
||||
method: 'DELETE',
|
||||
path: '/uploads/tenant-123/user-123/file.txt',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/files',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(out).toContain('"request_method":"POST"');
|
||||
expect(out).toContain('"request_path":"/api/files"');
|
||||
expect(out).not.toContain('"method":"DELETE"');
|
||||
expect(out).not.toContain('/uploads/tenant-123/user-123/file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonTruncateFormat structured events', () => {
|
||||
it('preserves short auth correlation fields when the human-readable message is truncated', () => {
|
||||
const format = jsonTruncateFormat();
|
||||
const info = {
|
||||
level: 'warn',
|
||||
message: `JWT auth rejected: ${'x'.repeat(300)}`,
|
||||
event_name: 'jwt_auth_rejected',
|
||||
request_id: 'request-123',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
token_source: 'bearer',
|
||||
reason_category: 'malformed_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
} satisfies winston.Logform.TransformableInfo;
|
||||
|
||||
const output = format.transform(info, format.options);
|
||||
if (typeof output !== 'object') {
|
||||
throw new Error('Expected jsonTruncateFormat to preserve the log record');
|
||||
}
|
||||
|
||||
expect(output.message).toBe(`${info.message.slice(0, 255)}...`);
|
||||
expect(output).toMatchObject({
|
||||
event_name: 'jwt_auth_rejected',
|
||||
request_id: 'request-123',
|
||||
request_method: 'GET',
|
||||
request_path: '/api/messages',
|
||||
token_source: 'bearer',
|
||||
reason_category: 'malformed_jwt',
|
||||
recovery_classification: 'terminal_rejection',
|
||||
response_status: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { klona } from 'klona';
|
||||
import winston from 'winston';
|
||||
import type { TraverseContext } from '../utils/object-traverse';
|
||||
import { appendLogContext } from './requestLogContext';
|
||||
import { SYSTEM_TENANT_ID } from './tenantContext';
|
||||
import traverse from '../utils/object-traverse';
|
||||
|
||||
|
|
@ -9,7 +10,6 @@ const MESSAGE_SYMBOL = Symbol.for('message');
|
|||
const CONSOLE_JSON_STRING_LENGTH: number =
|
||||
parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255;
|
||||
const DEBUG_MESSAGE_LENGTH: number = parseInt(process.env.DEBUG_MESSAGE_LENGTH || '', 10) || 150;
|
||||
const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const;
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
const REDACTION_TRUNCATED_KEY = '__redaction_truncated__';
|
||||
const MAX_REDACTION_DEPTH = 8;
|
||||
|
|
@ -396,25 +396,6 @@ const condenseArray = (item: unknown): string | unknown => {
|
|||
return item;
|
||||
};
|
||||
|
||||
function formatRequestContext(metadata: Record<string, unknown>): string {
|
||||
const context: Partial<Record<(typeof LOG_CONTEXT_KEYS)[number], string>> = {};
|
||||
LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
const value = metadata[key];
|
||||
if (key === 'tenantId' && value === SYSTEM_TENANT_ID) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string' && value) {
|
||||
context[key] = value;
|
||||
}
|
||||
});
|
||||
return Object.keys(context).length > 0 ? JSON.stringify(context) : '';
|
||||
}
|
||||
|
||||
function appendRequestContext(line: string, metadata: Record<string, unknown>): string {
|
||||
const context = formatRequestContext(metadata);
|
||||
return context ? `${line} ${context}` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats log messages for debugging purposes.
|
||||
* - Truncates long strings within log messages.
|
||||
|
|
@ -442,7 +423,7 @@ const debugTraverse: winston.Logform.Format = winston.format.printf(
|
|||
|
||||
try {
|
||||
if (level !== 'debug') {
|
||||
return appendRequestContext(msgParts[0], metadata);
|
||||
return appendLogContext(msgParts[0], metadata);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
|
|
@ -455,17 +436,17 @@ const debugTraverse: winston.Logform.Format = winston.format.printf(
|
|||
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
|
||||
|
||||
if (!debugValue) {
|
||||
return appendRequestContext(msgParts[0], metadata);
|
||||
return appendLogContext(msgParts[0], metadata);
|
||||
}
|
||||
|
||||
if (debugValue && Array.isArray(debugValue)) {
|
||||
msgParts.push(`\n${JSON.stringify(debugValue.map(condenseArray))}`);
|
||||
return appendRequestContext(msgParts.join(''), metadata);
|
||||
return appendLogContext(msgParts.join(''), metadata);
|
||||
}
|
||||
|
||||
if (typeof debugValue !== 'object') {
|
||||
msgParts.push(` ${debugValue}`);
|
||||
return appendRequestContext(msgParts.join(''), metadata);
|
||||
return appendLogContext(msgParts.join(''), metadata);
|
||||
}
|
||||
|
||||
msgParts.push('\n{');
|
||||
|
|
|
|||
109
packages/data-schemas/src/config/requestLogContext.spec.ts
Normal file
109
packages/data-schemas/src/config/requestLogContext.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { attachRequestContext, formatLogContext } from './requestLogContext';
|
||||
import { tenantStorage } from './tenantContext';
|
||||
|
||||
describe('attachRequestContext', () => {
|
||||
const context = {
|
||||
tenantId: 'tenant-123',
|
||||
userId: 'user-123',
|
||||
requestId: 'request-123',
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/api/example',
|
||||
};
|
||||
|
||||
it.each([
|
||||
'jwt_auth_fallback_attempt',
|
||||
'jwt_auth_rejected',
|
||||
'jwt_auth_recovered',
|
||||
'tenant_isolation_error',
|
||||
])('omits identity fields from %s while preserving request correlation', (eventName) =>
|
||||
tenantStorage.run(context, () => {
|
||||
const info = {
|
||||
level: 'warn',
|
||||
message: 'event',
|
||||
event_name: eventName,
|
||||
reason_category: 'malformed_jwt',
|
||||
response_status: 401,
|
||||
tenantId: 'explicit-tenant',
|
||||
tenant_id: 'explicit-tenant',
|
||||
userId: 'explicit-user',
|
||||
user_id: 'explicit-user',
|
||||
};
|
||||
|
||||
const result = attachRequestContext(info);
|
||||
|
||||
expect(result).not.toHaveProperty('tenantId');
|
||||
expect(result).not.toHaveProperty('tenant_id');
|
||||
expect(result).not.toHaveProperty('userId');
|
||||
expect(result).not.toHaveProperty('user_id');
|
||||
expect(result).toMatchObject({
|
||||
requestId: 'request-123',
|
||||
request_id: 'request-123',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/example',
|
||||
});
|
||||
const rendered = formatLogContext(result);
|
||||
expect(rendered).toContain(`"event_name":"${eventName}"`);
|
||||
expect(rendered).toContain('"reason_category":"malformed_jwt"');
|
||||
expect(rendered).toContain('"response_status":401');
|
||||
expect(rendered).not.toContain('explicit-tenant');
|
||||
expect(rendered).not.toContain('explicit-user');
|
||||
}),
|
||||
);
|
||||
|
||||
it('retains identity context on ordinary application logs', () =>
|
||||
tenantStorage.run(context, () => {
|
||||
const result = attachRequestContext({ level: 'info', message: 'event' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
tenantId: 'tenant-123',
|
||||
userId: 'user-123',
|
||||
requestId: 'request-123',
|
||||
request_id: 'request-123',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/example',
|
||||
});
|
||||
}));
|
||||
|
||||
it('keeps application paths separate from the safe request route', () =>
|
||||
tenantStorage.run(context, () => {
|
||||
const result = attachRequestContext({
|
||||
level: 'error',
|
||||
message: 'upload cleanup failed',
|
||||
method: 'DELETE',
|
||||
path: '/uploads/tenant-123/user-123/file.txt',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
method: 'DELETE',
|
||||
path: '/uploads/tenant-123/user-123/file.txt',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/example',
|
||||
});
|
||||
const rendered = formatLogContext(result);
|
||||
expect(rendered).toContain('"request_method":"POST"');
|
||||
expect(rendered).toContain('"request_path":"/api/example"');
|
||||
expect(rendered).not.toContain('"method":"DELETE"');
|
||||
expect(rendered).not.toContain('/uploads/tenant-123/user-123/file.txt');
|
||||
}));
|
||||
|
||||
it('overwrites reserved request fields with the safe ALS correlation context', () =>
|
||||
tenantStorage.run(context, () => {
|
||||
const result = attachRequestContext({
|
||||
level: 'warn',
|
||||
message: 'event',
|
||||
requestId: 'header..signature',
|
||||
request_id: 'header..signature',
|
||||
request_method: 'DELETE',
|
||||
request_path: '/unsafe/concrete/path',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
requestId: 'request-123',
|
||||
request_id: 'request-123',
|
||||
request_method: 'POST',
|
||||
request_path: '/api/example',
|
||||
});
|
||||
expect(formatLogContext(result)).not.toContain('header..signature');
|
||||
expect(formatLogContext(result)).not.toContain('/unsafe/concrete/path');
|
||||
}));
|
||||
});
|
||||
160
packages/data-schemas/src/config/requestLogContext.ts
Normal file
160
packages/data-schemas/src/config/requestLogContext.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import type winston from 'winston';
|
||||
import {
|
||||
getTenantId,
|
||||
getUserId,
|
||||
getRequestId,
|
||||
getRequestMethod,
|
||||
getRequestPath,
|
||||
SYSTEM_TENANT_ID,
|
||||
} from './tenantContext';
|
||||
|
||||
const REQUEST_LOG_CONTEXT_KEYS = [
|
||||
'tenantId',
|
||||
'userId',
|
||||
'requestId',
|
||||
'request_id',
|
||||
'request_method',
|
||||
'request_path',
|
||||
] as const;
|
||||
|
||||
const RESERVED_REQUEST_LOG_CONTEXT_KEYS = new Set<string>([
|
||||
'requestId',
|
||||
'request_id',
|
||||
'request_method',
|
||||
'request_path',
|
||||
]);
|
||||
|
||||
const STRUCTURED_EVENT_LOG_CONTEXT_KEYS = [
|
||||
'event_name',
|
||||
'auth_strategy',
|
||||
'primary_strategy',
|
||||
'fallback_strategy',
|
||||
'fallback_attempted',
|
||||
'fallback_succeeded',
|
||||
'attempted_strategies',
|
||||
'final_strategy',
|
||||
'primary_failure_reason_category',
|
||||
'reason_category',
|
||||
'recovery_classification',
|
||||
'response_status',
|
||||
'strategy_status',
|
||||
'token_provider',
|
||||
'token_source',
|
||||
'openid_reuse_enabled',
|
||||
'openid_jwt_available',
|
||||
'has_openid_reuse_user_id',
|
||||
'error_category',
|
||||
'error_signature',
|
||||
] as const;
|
||||
|
||||
type LogContextKey =
|
||||
| (typeof REQUEST_LOG_CONTEXT_KEYS)[number]
|
||||
| (typeof STRUCTURED_EVENT_LOG_CONTEXT_KEYS)[number];
|
||||
|
||||
const LOG_CONTEXT_KEYS: readonly LogContextKey[] = [
|
||||
...REQUEST_LOG_CONTEXT_KEYS,
|
||||
...STRUCTURED_EVENT_LOG_CONTEXT_KEYS,
|
||||
];
|
||||
|
||||
const IDENTITY_CONTEXT_KEYS = ['tenantId', 'tenant_id', 'userId', 'user_id'] as const;
|
||||
const IDENTITY_FREE_EVENT_NAMES = new Set([
|
||||
'jwt_auth_fallback_attempt',
|
||||
'jwt_auth_rejected',
|
||||
'jwt_auth_recovered',
|
||||
'tenant_isolation_error',
|
||||
]);
|
||||
const STRUCTURED_EVENT_LOG_CONTEXT_KEY_SET = new Set<string>(STRUCTURED_EVENT_LOG_CONTEXT_KEYS);
|
||||
const MAX_LOG_CONTEXT_ARRAY_LENGTH = 10;
|
||||
|
||||
type LogContextValue = string | number | boolean | readonly string[];
|
||||
type LogContextInfo =
|
||||
| Partial<{ [Key in LogContextKey]: unknown }>
|
||||
| winston.Logform.TransformableInfo;
|
||||
|
||||
function isIdentityFreeEvent(eventName: unknown): boolean {
|
||||
return typeof eventName === 'string' && IDENTITY_FREE_EVENT_NAMES.has(eventName);
|
||||
}
|
||||
|
||||
function getLogTenantId(): string | undefined {
|
||||
const tenantId = getTenantId();
|
||||
return tenantId === SYSTEM_TENANT_ID ? undefined : tenantId;
|
||||
}
|
||||
|
||||
function normalizeLogContextValue(value: unknown): LogContextValue | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value || undefined;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
.slice(0, MAX_LOG_CONTEXT_ARRAY_LENGTH);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
/** Serializes the allowlisted context rendered by non-JSON log transports. */
|
||||
export function formatLogContext(info: LogContextInfo): string {
|
||||
const context: Partial<Record<LogContextKey, LogContextValue>> = {};
|
||||
const omitIdentity = isIdentityFreeEvent(info.event_name);
|
||||
|
||||
LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
if (STRUCTURED_EVENT_LOG_CONTEXT_KEY_SET.has(key) && !omitIdentity) {
|
||||
return;
|
||||
}
|
||||
if (omitIdentity && (key === 'tenantId' || key === 'userId')) {
|
||||
return;
|
||||
}
|
||||
if (key === 'tenantId' && info[key] === SYSTEM_TENANT_ID) {
|
||||
return;
|
||||
}
|
||||
const value = normalizeLogContextValue(info[key]);
|
||||
if (value !== undefined) {
|
||||
context[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(context).length > 0 ? JSON.stringify(context) : '';
|
||||
}
|
||||
|
||||
export function appendLogContext(line: string, info: LogContextInfo): string {
|
||||
const context = formatLogContext(info);
|
||||
return context ? `${line} ${context}` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds request-scoped context to a log record. Authentication and tenant-isolation
|
||||
* events intentionally omit identity fields while retaining request correlation.
|
||||
*/
|
||||
export function attachRequestContext(
|
||||
info: winston.Logform.TransformableInfo,
|
||||
): winston.Logform.TransformableInfo {
|
||||
const omitIdentity = isIdentityFreeEvent(info.event_name);
|
||||
if (omitIdentity) {
|
||||
IDENTITY_CONTEXT_KEYS.forEach((key) => delete info[key]);
|
||||
} else if (info.tenantId === SYSTEM_TENANT_ID) {
|
||||
delete info.tenantId;
|
||||
}
|
||||
|
||||
const context = {
|
||||
tenantId: omitIdentity ? undefined : getLogTenantId(),
|
||||
userId: omitIdentity ? undefined : getUserId(),
|
||||
requestId: getRequestId(),
|
||||
request_id: getRequestId(),
|
||||
request_method: getRequestMethod(),
|
||||
request_path: getRequestPath(),
|
||||
};
|
||||
REQUEST_LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
if (context[key] && (RESERVED_REQUEST_LOG_CONTEXT_KEYS.has(key) || info[key] == null)) {
|
||||
info[key] = context[key];
|
||||
}
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ export interface TenantContext {
|
|||
tenantId?: string;
|
||||
userId?: string;
|
||||
requestId?: string;
|
||||
requestMethod?: string;
|
||||
requestPath?: string;
|
||||
}
|
||||
|
||||
/** Sentinel value for deliberate cross-tenant system operations */
|
||||
|
|
@ -32,13 +34,26 @@ export function getRequestId(): string | undefined {
|
|||
return tenantStorage.getStore()?.requestId;
|
||||
}
|
||||
|
||||
/** Returns the safe request method from async context, or undefined if none is set */
|
||||
export function getRequestMethod(): string | undefined {
|
||||
return tenantStorage.getStore()?.requestMethod;
|
||||
}
|
||||
|
||||
/** Returns the safe request path from async context, or undefined if none is set */
|
||||
export function getRequestPath(): string | undefined {
|
||||
return tenantStorage.getStore()?.requestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function in an explicit cross-tenant system context (bypasses tenant filtering).
|
||||
* The callback MUST be async — sync callbacks returning Mongoose thenables will lose context.
|
||||
*/
|
||||
export function runAsSystem<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const { requestId, userId } = tenantStorage.getStore() ?? {};
|
||||
return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID, requestId, userId }, fn);
|
||||
const { requestId, userId, requestMethod, requestPath } = tenantStorage.getStore() ?? {};
|
||||
return tenantStorage.run(
|
||||
{ tenantId: SYSTEM_TENANT_ID, requestId, userId, requestMethod, requestPath },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
jsonTruncateFormat,
|
||||
stripHeavyErrorFields,
|
||||
} from './parsers';
|
||||
import { getTenantId, getUserId, getRequestId, SYSTEM_TENANT_ID } from './tenantContext';
|
||||
import { appendLogContext, attachRequestContext } from './requestLogContext';
|
||||
import { getLogDirectory } from './utils';
|
||||
|
||||
const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE, LOG_TO_FILE } = process.env;
|
||||
|
|
@ -31,48 +31,7 @@ const levels: winston.config.AbstractConfigSetLevels = {
|
|||
silly: 7,
|
||||
};
|
||||
|
||||
const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const;
|
||||
|
||||
function getLogTenantId(): string | undefined {
|
||||
const tenantId = getTenantId();
|
||||
return tenantId === SYSTEM_TENANT_ID ? undefined : tenantId;
|
||||
}
|
||||
|
||||
const requestContextFormat = winston.format((info: winston.Logform.TransformableInfo) => {
|
||||
if (info.tenantId === SYSTEM_TENANT_ID) {
|
||||
delete info.tenantId;
|
||||
}
|
||||
const context = {
|
||||
tenantId: getLogTenantId(),
|
||||
userId: getUserId(),
|
||||
requestId: getRequestId(),
|
||||
};
|
||||
LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
if (context[key] && info[key] == null) {
|
||||
info[key] = context[key];
|
||||
}
|
||||
});
|
||||
return info;
|
||||
});
|
||||
|
||||
function formatRequestContext(info: winston.Logform.TransformableInfo): string {
|
||||
const context: Partial<Record<(typeof LOG_CONTEXT_KEYS)[number], string>> = {};
|
||||
LOG_CONTEXT_KEYS.forEach((key) => {
|
||||
const value = info[key];
|
||||
if (key === 'tenantId' && value === SYSTEM_TENANT_ID) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string' && value) {
|
||||
context[key] = value;
|
||||
}
|
||||
});
|
||||
return Object.keys(context).length > 0 ? JSON.stringify(context) : '';
|
||||
}
|
||||
|
||||
function appendRequestContext(line: string, info: winston.Logform.TransformableInfo): string {
|
||||
const context = formatRequestContext(info);
|
||||
return context ? `${line} ${context}` : line;
|
||||
}
|
||||
const requestContextFormat = winston.format(attachRequestContext);
|
||||
|
||||
winston.addColors({
|
||||
info: 'green',
|
||||
|
|
@ -134,7 +93,7 @@ const consoleFormat = winston.format.combine(
|
|||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf((info) => {
|
||||
const message = `${info.timestamp} ${info.level}: ${info.message}`;
|
||||
const line = appendRequestContext(message, info);
|
||||
const line = appendLogContext(message, info);
|
||||
return info.level.includes('error') ? redactMessage(line) : line;
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export {
|
|||
getTenantId,
|
||||
getUserId,
|
||||
getRequestId,
|
||||
getRequestMethod,
|
||||
getRequestPath,
|
||||
runAsSystem,
|
||||
scopedCacheKey,
|
||||
SYSTEM_TENANT_ID,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue