mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Add lastActiveAt datetime column on user table (#16488)
This commit is contained in:
committed by
GitHub
parent
1086914080
commit
92afe036dd
@@ -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({
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
46
packages/cli/src/services/last-active-at.service.ts
Normal file
46
packages/cli/src/services/last-active-at.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user