feat(core): Add lastActiveAt datetime column on user table (#16488)

This commit is contained in:
Guillaume Jacquart
2025-06-24 16:20:10 +02:00
committed by GitHub
parent 1086914080
commit 92afe036dd
13 changed files with 259 additions and 107 deletions

View File

@@ -36,6 +36,7 @@ export const userListItemSchema = z.object({
lastActive: z.string().optional(),
projectRelations: z.array(userProjectSchema).nullable().optional(),
mfaEnabled: z.boolean().optional(),
lastActiveAt: z.string().nullable().optional(),
});
export const usersListSchema = z.object({

View File

@@ -112,6 +112,7 @@ export interface PublicUser {
inviteAcceptUrl?: string;
isOwner?: boolean;
featureFlags?: FeatureFlags; // External type from n8n-workflow
lastActiveAt?: Date | null;
}
export type UserSettings = Pick<User, 'id' | 'settings'>;

View File

@@ -102,6 +102,9 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal {
@Column({ type: 'simple-array', default: '' })
mfaRecoveryCodes: string[];
@Column({ type: 'date', nullable: true })
lastActiveAt?: Date | null;
/**
* Whether the user is pending setup completion.
*/

View File

@@ -0,0 +1,20 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columnName = 'lastActiveAt';
const tableName = 'user';
export class AddLastActiveAtColumnToUser1750252139166 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} DATE NULL`);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}

View File

@@ -87,6 +87,7 @@ import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/174558708
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser';
import type { Migration } from '../migration-types';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
@@ -181,4 +182,5 @@ export const mysqlMigrations: Migration[] = [
DropRoleTable1745934666077,
ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
];

View File

@@ -87,6 +87,7 @@ import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/174558708
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@@ -179,4 +180,5 @@ export const postgresMigrations: Migration[] = [
DropRoleTable1745934666077,
ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
];

View File

@@ -84,6 +84,7 @@ import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/174558708
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -172,6 +173,7 @@ const sqliteMigrations: Migration[] = [
DropRoleTable1745934666077,
ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
];
export { sqliteMigrations };

View File

@@ -17,6 +17,7 @@ import { agent as testAgent } from 'supertest';
import type { AuthService } from '@/auth/auth.service';
import { ControllerRegistry } from '@/controller.registry';
import type { License } from '@/license';
import type { LastActiveAtService } from '@/services/last-active-at.service';
import type { SuperAgentTest } from '@test-integration/types';
describe('ControllerRegistry', () => {
@@ -24,12 +25,19 @@ describe('ControllerRegistry', () => {
const authService = mock<AuthService>();
const globalConfig = mock<GlobalConfig>({ endpoints: { rest: 'rest' } });
const metadata = Container.get(ControllerRegistryMetadata);
const lastActiveAtService = mock<LastActiveAtService>();
let agent: SuperAgentTest;
beforeEach(() => {
jest.resetAllMocks();
const app = express();
new ControllerRegistry(license, authService, globalConfig, metadata).activate(app);
new ControllerRegistry(
license,
authService,
globalConfig,
metadata,
lastActiveAtService,
).activate(app);
agent = testAgent(app);
});
@@ -50,6 +58,7 @@ describe('ControllerRegistry', () => {
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});
it('should not rate-limit by default', async () => {
@@ -108,6 +117,7 @@ describe('ControllerRegistry', () => {
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});
it('should disallow when feature is missing', async () => {
@@ -136,6 +146,7 @@ describe('ControllerRegistry', () => {
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});
it('should pass in correct args to the route handler', async () => {

View File

@@ -19,6 +19,7 @@ import type { AuthenticatedRequest } from '@/requests';
import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file
import { NotFoundError } from './errors/response-errors/not-found.error';
import { LastActiveAtService } from './services/last-active-at.service';
@Service()
export class ControllerRegistry {
@@ -27,6 +28,7 @@ export class ControllerRegistry {
private readonly authService: AuthService,
private readonly globalConfig: GlobalConfig,
private readonly metadata: ControllerRegistryMetadata,
private readonly lastActiveAtService: LastActiveAtService,
) {}
activate(app: Application) {
@@ -82,7 +84,12 @@ export class ControllerRegistry {
? [this.createRateLimitMiddleware(route.rateLimit)]
: []),
// eslint-disable-next-line @typescript-eslint/unbound-method
...(route.skipAuth ? [] : [this.authService.authMiddleware]),
...(route.skipAuth
? []
: ([
this.authService.authMiddleware.bind(this.authService),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
] as RequestHandler[])),
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []),
...controllerMiddlewares,

View File

@@ -0,0 +1,113 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { User } from '@n8n/db';
import type { UserRepository } from '@n8n/db';
import type { NextFunction, Response } from 'express';
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import type { AuthenticatedRequest } from '@/requests';
import { LastActiveAtService } from '@/services/last-active-at.service';
describe('LastActiveAtService', () => {
const userData = {
id: '123',
email: 'test@example.com',
password: 'passwordHash',
disabled: false,
mfaEnabled: false,
};
const user = mock<User>(userData);
const globalConfig = mock<GlobalConfig>({ auth: { cookie: { secure: true, samesite: 'lax' } } });
let queryBuilderMock = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const userRepository = mock<UserRepository>();
const lastActiveAtService = new LastActiveAtService(userRepository, mockLogger());
const now = new Date('2024-02-01T01:23:45.678Z');
jest.useFakeTimers({ now });
beforeEach(() => {
jest.resetAllMocks();
jest.setSystemTime(now);
globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
queryBuilderMock = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
(userRepository.createQueryBuilder as jest.Mock).mockReturnValue(queryBuilderMock);
});
describe('middleware', () => {
const req = mock<AuthenticatedRequest>({
user,
});
const res = mock<Response>();
const next = jest.fn() as NextFunction;
beforeEach(() => {
// reset the last active time cache (private variable)
(lastActiveAtService as any).lastActiveCache = new Map();
req.user = user;
});
it('should update last active time if user not in cache', async () => {
await lastActiveAtService.middleware(req, res, next);
expect(queryBuilderMock.update).toHaveBeenCalled();
expect(queryBuilderMock.set).toHaveBeenCalledWith({ lastActiveAt: expect.any(Date) });
expect(queryBuilderMock.where).toHaveBeenCalledWith('id = :id', { id: user.id });
expect(queryBuilderMock.execute).toHaveBeenCalled();
});
it('should not update last active time if user is in cache', async () => {
await lastActiveAtService.middleware(req, res, next);
expect(queryBuilderMock.update).toHaveBeenCalled();
expect(queryBuilderMock.set).toHaveBeenCalledWith({ lastActiveAt: expect.any(Date) });
expect(queryBuilderMock.where).toHaveBeenCalledWith('id = :id', { id: user.id });
expect(queryBuilderMock.execute).toHaveBeenCalled();
// Call middleware again now that the user is in cache
queryBuilderMock.execute.mockClear();
await lastActiveAtService.middleware(req, res, next);
expect(queryBuilderMock.execute).not.toHaveBeenCalled();
});
it('should update last active time if user is in cache but stale', async () => {
// Call middleware once to set the initial last active time
await lastActiveAtService.middleware(req, res, next);
expect(queryBuilderMock.update).toHaveBeenCalled();
expect(queryBuilderMock.set).toHaveBeenCalledWith({ lastActiveAt: expect.any(Date) });
expect(queryBuilderMock.where).toHaveBeenCalledWith('id = :id', { id: user.id });
expect(queryBuilderMock.execute).toHaveBeenCalled();
queryBuilderMock.update.mockClear();
queryBuilderMock.set.mockClear();
queryBuilderMock.where.mockClear();
queryBuilderMock.execute.mockClear();
jest.advanceTimersByTime(24 * Time.hours.toMilliseconds);
// Call middleware again now that the user is in cache with a stale last active time
await lastActiveAtService.middleware(req, res, next);
expect(queryBuilderMock.update).toHaveBeenCalled();
expect(queryBuilderMock.set).toHaveBeenCalledWith({
lastActiveAt: DateTime.fromJSDate(now).plus({ days: 1 }).startOf('day').toJSDate(),
});
expect(queryBuilderMock.where).toHaveBeenCalledWith('id = :id', { id: user.id });
expect(queryBuilderMock.execute).toHaveBeenCalled();
});
});
});

View File

@@ -12,9 +12,11 @@ import type { OpenAPIV3 } from 'openapi-types';
import type { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
import { createAdminWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
import { retryUntil } from '@test-integration/retry-until';
import * as testDb from '@test-integration/test-db';
import { JwtService } from '../jwt.service';
import { LastActiveAtService } from '../last-active-at.service';
import { PublicApiKeyService } from '../public-api-key.service';
const mockReqWith = (apiKey: string, path: string, method: string) => {
@@ -39,6 +41,8 @@ const jwtService = new JwtService(instanceSettings);
let userRepository: UserRepository;
let apiKeyRepository: ApiKeyRepository;
let lastActiveAtService: LastActiveAtService;
let publicApiKeyService: PublicApiKeyService;
describe('PublicApiKeyService', () => {
beforeEach(async () => {
@@ -50,6 +54,14 @@ describe('PublicApiKeyService', () => {
await testDb.init();
userRepository = Container.get(UserRepository);
apiKeyRepository = Container.get(ApiKeyRepository);
lastActiveAtService = Container.get(LastActiveAtService);
publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
lastActiveAtService,
);
});
afterAll(async () => {
@@ -65,13 +77,6 @@ describe('PublicApiKeyService', () => {
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
//Act
@@ -91,13 +96,6 @@ describe('PublicApiKeyService', () => {
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
//Act
@@ -116,13 +114,6 @@ describe('PublicApiKeyService', () => {
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const owner = await createOwnerWithApiKey();
const [{ apiKey }] = owner.apiKeys;
@@ -155,13 +146,6 @@ describe('PublicApiKeyService', () => {
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const dateInThePast = DateTime.now().minus({ days: 1 }).toUnixInteger();
const owner = await createOwnerWithApiKey({
@@ -189,13 +173,6 @@ describe('PublicApiKeyService', () => {
const apiVersion = 'v1';
const legacyApiKey = `n8n_api_${randomString(10)}`;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const owner = await createOwnerWithApiKey();
const [{ apiKey }] = owner.apiKeys;
@@ -226,6 +203,37 @@ describe('PublicApiKeyService', () => {
}),
);
});
it('should update last active at for the user', async () => {
// Arrange
const path = '/test';
const method = 'GET';
const apiVersion = 'v1';
const owner = await createOwnerWithApiKey();
const [{ apiKey }] = owner.apiKeys;
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
// Act
await middleware(mockReqWith(apiKey, path, method), {}, securitySchema);
// Wait for the fire and forget job to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
// Assert
// Poll the database to check if lastActiveAt was updated
await retryUntil(async () => {
const userOnDb = await userRepository.findOneByOrFail({ id: owner.id });
expect(userOnDb.lastActiveAt).toBeDefined();
expect(DateTime.fromSQL(userOnDb.lastActiveAt!.toString()).toJSDate().getTime()).toEqual(
DateTime.now().startOf('day').toMillis(),
);
});
});
});
describe('redactApiKey', () => {
@@ -235,13 +243,6 @@ describe('PublicApiKeyService', () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0ODUxNDA5ODQsImlhdCI6MTQ4NTEzNzM4NCwiaXNzIjoiYWNtZS5jb20iLCJzdWIiOiIyOWFjMGMxOC0wYjRhLTQyY2YtODJmYy0wM2Q1NzAzMThhMWQiLCJhcHBsaWNhdGlvbklkIjoiNzkxMDM3MzQtOTdhYi00ZDFhLWFmMzctZTAwNmQwNWQyOTUyIiwicm9sZXMiOltdfQ.Mp0Pcwsz5VECK11Kf2ZZNF_SMKu5CgBeLN9ZOP04kZo';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
//Act
const redactedApiKey = publicApiKeyService.redactApiKey(jwt);
@@ -260,13 +261,6 @@ describe('PublicApiKeyService', () => {
const apiKeyId = adminUser.apiKeys[0].id;
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
await publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(adminUser);
@@ -300,13 +294,6 @@ describe('PublicApiKeyService', () => {
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
@@ -335,13 +322,6 @@ describe('PublicApiKeyService', () => {
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
@@ -366,13 +346,6 @@ describe('PublicApiKeyService', () => {
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
@@ -400,13 +373,6 @@ describe('PublicApiKeyService', () => {
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
@@ -429,13 +395,6 @@ describe('PublicApiKeyService', () => {
const apiKey = owner.apiKeys[0].apiKey;
const requiredScope = 'workflow:read' as ApiKeyScope;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const result = await publicApiKeyService.apiKeyHasValidScopes(apiKey, requiredScope);
@@ -453,13 +412,6 @@ describe('PublicApiKeyService', () => {
const apiKey = owner.apiKeys[0].apiKey;
const requiredScope = 'workflow:read' as ApiKeyScope;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const result = await publicApiKeyService.apiKeyHasValidScopes(apiKey, requiredScope);
@@ -471,13 +423,6 @@ describe('PublicApiKeyService', () => {
describe('apiKeyHasValidScopesForRole', () => {
it('should return true if API key has the required scope for the role', async () => {
// Arrange
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
// Act
@@ -494,13 +439,6 @@ describe('PublicApiKeyService', () => {
it('should return false if API key does not have the required scope for the role', async () => {
// Arrange
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
// Act

View File

@@ -0,0 +1,46 @@
import { Logger } from '@n8n/backend-common';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { NextFunction, Response } from 'express';
import { DateTime } from 'luxon';
import type { AuthenticatedRequest } from '@/requests';
@Service()
export class LastActiveAtService {
private readonly lastActiveCache = new Map<string, string>();
constructor(
private readonly userRepository: UserRepository,
private readonly logger: Logger,
) {}
async middleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
if (req.user) {
this.updateLastActiveIfStale(req.user.id).catch((error: unknown) => {
this.logger.error('Failed to update last active timestamp', { error });
});
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
}
async updateLastActiveIfStale(userId: string) {
const now = DateTime.now().startOf('day');
const dateNow = now.toISODate();
const last = this.lastActiveCache.get(userId);
// Update if date changed (or not set)
if (!last || last !== dateNow) {
await this.userRepository
.createQueryBuilder()
.update()
.set({ lastActiveAt: now.toJSDate() })
.where('id = :id', { id: userId })
.execute();
this.lastActiveCache.set(userId, dateNow);
}
}
}

View File

@@ -15,6 +15,7 @@ import { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
import { JwtService } from './jwt.service';
import { LastActiveAtService } from './last-active-at.service';
const API_KEY_AUDIENCE = 'public-api';
const API_KEY_ISSUER = 'n8n';
@@ -29,6 +30,7 @@ export class PublicApiKeyService {
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly eventService: EventService,
private readonly lastActiveAtService: LastActiveAtService,
) {}
/**
@@ -139,6 +141,10 @@ export class PublicApiKeyService {
req.user = user;
// TODO: ideally extract that to a dedicated middleware, but express-openapi-validator
// does not support middleware between authentication and operators
void this.lastActiveAtService.updateLastActiveIfStale(user.id);
return true;
};
}