diff --git a/packages/@n8n/config/src/configs/user-management.config.ts b/packages/@n8n/config/src/configs/user-management.config.ts index 296a0a408c8..20748e17697 100644 --- a/packages/@n8n/config/src/configs/user-management.config.ts +++ b/packages/@n8n/config/src/configs/user-management.config.ts @@ -77,6 +77,10 @@ export class TemplateConfig { /** Overrides default HTML template for notifying that a workflow failed in production (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_FAILURE') 'workflow-failure': string = ''; + + /** Overrides default HTML template for notifying a user that their public API key was revoked by an admin (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_API_KEY_REVOKED') + 'api-key-revoked': string = ''; } const emailModeSchema = z.enum(['', 'smtp']); diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index cf71aeab3f7..a4661e7bf2d 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -159,6 +159,7 @@ describe('GlobalConfig', () => { 'workflow-failure': '', 'workflow-shared': '', 'project-shared': '', + 'api-key-revoked': '', }, }, }, diff --git a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts index 4073cae52b7..ec4bad31592 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts @@ -25,7 +25,7 @@ describe('PublicApiKeyService', () => { beforeAll(async () => { await testDb.init(); apiKeyRepository = Container.get(ApiKeyRepository); - publicApiKeyService = new PublicApiKeyService(apiKeyRepository, jwtService); + publicApiKeyService = new PublicApiKeyService(apiKeyRepository, jwtService, mock(), mock()); }); afterAll(async () => { diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts new file mode 100644 index 00000000000..1af43b5621b --- /dev/null +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@n8n/backend-common'; +import type { ApiKey, ApiKeyRepository, User } from '@n8n/db'; +import { hasGlobalScope } from '@n8n/permissions'; +import { mock } from 'jest-mock-extended'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { UserManagementMailer } from '@/user-management/email'; + +import type { JwtService } from '../jwt.service'; +import { PublicApiKeyService } from '../public-api-key.service'; + +jest.mock('@n8n/permissions', () => ({ + ...jest.requireActual('@n8n/permissions'), + hasGlobalScope: jest.fn(), +})); + +describe('PublicApiKeyService', () => { + const apiKeyRepository = mock(); + const jwtService = mock(); + const mailer = mock(); + const logger = mock(); + + const service = new PublicApiKeyService(apiKeyRepository, jwtService, mailer, logger); + + const hasGlobalScopeMock = jest.mocked(hasGlobalScope); + + beforeEach(() => { + jest.clearAllMocks(); + mailer.notifyApiKeyRevoked.mockResolvedValue({ emailSent: true }); + }); + + describe('deleteApiKey', () => { + const apiKey = mock({ + id: 'key-1', + userId: 'owner-1', + user: mock({ id: 'owner-1' }), + }); + + const owner = mock({ id: 'owner-1' }); + const admin = mock({ id: 'admin-1' }); + + it('does not send an email when the owner deletes their own key', async () => { + hasGlobalScopeMock.mockReturnValue(false); + apiKeyRepository.findOne.mockResolvedValue(apiKey); + apiKeyRepository.delete.mockResolvedValue({ affected: 1, raw: [] }); + + const result = await service.deleteApiKey(owner, 'key-1'); + + expect(result).toEqual({ isOwn: true }); + expect(mailer.notifyApiKeyRevoked).not.toHaveBeenCalled(); + }); + + it('delegates the revocation email to the mailer when an admin revokes another user’s key', async () => { + hasGlobalScopeMock.mockReturnValue(true); + apiKeyRepository.findOne.mockResolvedValue(apiKey); + apiKeyRepository.delete.mockResolvedValue({ affected: 1, raw: [] }); + + const result = await service.deleteApiKey(admin, 'key-1'); + + expect(result).toEqual({ isOwn: false }); + expect(mailer.notifyApiKeyRevoked).toHaveBeenCalledWith({ apiKey, revoker: admin }); + }); + + it('logs and swallows when the revocation email fails to send', async () => { + hasGlobalScopeMock.mockReturnValue(true); + apiKeyRepository.findOne.mockResolvedValue(apiKey); + apiKeyRepository.delete.mockResolvedValue({ affected: 1, raw: [] }); + mailer.notifyApiKeyRevoked.mockRejectedValueOnce(new Error('smtp down')); + + const result = await service.deleteApiKey(admin, 'key-1'); + + expect(result).toEqual({ isOwn: false }); + // Flush microtasks so the fire-and-forget catch has a chance to run. + await new Promise(setImmediate); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to send API key revocation email', + expect.objectContaining({ + apiKeyId: 'key-1', + ownerId: 'owner-1', + error: 'smtp down', + }), + ); + }); + + it('throws NotFoundError when the API key does not exist', async () => { + hasGlobalScopeMock.mockReturnValue(false); + apiKeyRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteApiKey(owner, 'missing')).rejects.toThrow(NotFoundError); + expect(mailer.notifyApiKeyRevoked).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index 3c743797b6a..065086231d2 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -5,6 +5,7 @@ import type { UpdateApiKeyRequestDto, } from '@n8n/api-types'; import { LIST_API_KEYS_SORT_OPTIONS } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { ApiKey, ApiKeyRepository, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -20,6 +21,7 @@ import { import { randomUUID } from 'crypto'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { UserManagementMailer } from '@/user-management/email'; import { JwtService } from './jwt.service'; @@ -37,6 +39,8 @@ export class PublicApiKeyService { constructor( private readonly apiKeyRepository: ApiKeyRepository, private readonly jwtService: JwtService, + private readonly mailer: UserManagementMailer, + private readonly logger: Logger, ) {} async createPublicApiKeyForUser( @@ -172,17 +176,32 @@ export class PublicApiKeyService { // for the existence of another user's keys. async deleteApiKey(caller: User, apiKeyId: string) { const canDeleteAny = hasGlobalScope(caller, 'apiKey:manage'); - const apiKey = await this.apiKeyRepository.findOneBy({ - id: apiKeyId, - audience: API_KEY_AUDIENCE, - ...(canDeleteAny ? {} : { userId: caller.id }), + const apiKey = await this.apiKeyRepository.findOne({ + where: { + id: apiKeyId, + audience: API_KEY_AUDIENCE, + ...(canDeleteAny ? {} : { userId: caller.id }), + }, + relations: { user: true }, }); if (!apiKey) throw new NotFoundError('API key not found'); const result = await this.apiKeyRepository.delete({ id: apiKey.id }); if (!result.affected) throw new NotFoundError('API key not found'); - return { isOwn: apiKey.userId === caller.id }; + const isOwn = apiKey.userId === caller.id; + + if (!isOwn) { + this.mailer.notifyApiKeyRevoked({ apiKey, revoker: caller }).catch((e) => { + this.logger.error('Failed to send API key revocation email', { + apiKeyId: apiKey.id, + ownerId: apiKey.userId, + error: e instanceof Error ? e.message : String(e), + }); + }); + } + + return { isOwn }; } async deleteAllApiKeysForUser(user: User, tx?: EntityManager) { diff --git a/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts b/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts index 80a4bbcf3ea..7197afe8164 100644 --- a/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts +++ b/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts @@ -1,6 +1,6 @@ import { mockInstance } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; -import type { User, UserRepository } from '@n8n/db'; +import type { ApiKey, User, UserRepository } from '@n8n/db'; import { PROJECT_EDITOR_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions'; import { mock } from 'jest-mock-extended'; import type { IWorkflowBase } from 'n8n-workflow'; @@ -155,6 +155,65 @@ describe('UserManagementMailer', () => { }); }); + it('should send api key revoked notifications', async () => { + const apiKey = mock({ + id: 'key-1', + label: 'Test 123', + apiKey: 'n8n_api_xxxxxxxaaa5', + userId: 'owner-1', + user: mock({ + id: 'owner-1', + email: 'owner@example.com', + firstName: 'Maria', + lastName: 'Silva', + }), + }); + const revoker = mock({ + firstName: 'Jan', + lastName: 'Ostrówka', + email: 'jan@acme.test', + }); + + const result = await userManagementMailer.notifyApiKeyRevoked({ apiKey, revoker }); + + expect(result.emailSent).toBe(true); + expect(nodeMailer.sendMail).toHaveBeenCalledWith({ + emailRecipients: 'owner@example.com', + subject: 'Your n8n API key was revoked', + body: expect.stringContaining('href="https://n8n.url/settings/api"'), + }); + + const callBody = nodeMailer.sendMail.mock.calls[0][0].body as string; + expect(callBody).toContain('Test 123'); + expect(callBody).toContain('aaa5'); + expect(callBody).toContain('Jan Ostrówka'); + expect(callBody).toMatch(/\d{1,2} [A-Z][a-z]{2} \d{4}/); + }); + + it('falls back to the revoker email when no name is set', async () => { + const apiKey = mock({ + id: 'key-1', + label: 'Test 123', + apiKey: 'n8n_api_xxxxxxxaaa5', + userId: 'owner-1', + user: mock({ + id: 'owner-1', + email: 'owner@example.com', + firstName: 'Maria', + }), + }); + const revoker = mock({ + firstName: undefined, + lastName: undefined, + email: 'jan@acme.test', + }); + + await userManagementMailer.notifyApiKeyRevoked({ apiKey, revoker }); + + const callBody = nodeMailer.sendMail.mock.calls[0][0].body as string; + expect(callBody).toContain('jan@acme.test'); + }); + it('should send project share notifications', async () => { const sharer = mock({ firstName: 'Sharer', email: 'sharer@user.com' }); const newSharees = [ diff --git a/packages/cli/src/user-management/email/templates/api-key-revoked.mjml b/packages/cli/src/user-management/email/templates/api-key-revoked.mjml new file mode 100644 index 00000000000..7723dcfd391 --- /dev/null +++ b/packages/cli/src/user-management/email/templates/api-key-revoked.mjml @@ -0,0 +1,19 @@ + + + + + + + Your API key was revoked + + Your API key "{{label}}" (••••{{suffix}}) on {{domain}} was revoked by {{revokedBy}} on {{revokedAt}}. + + + Any integrations or scripts still using this key have stopped working. If you still need API access, you can create a new key. + + Create new API key + + + + + diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 5cdfa328fba..d0588be22f6 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -1,6 +1,6 @@ import { inTest, Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; -import type { User } from '@n8n/db'; +import type { ApiKey, User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { AssignableProjectRole } from '@n8n/permissions'; @@ -19,6 +19,25 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { UrlService } from '@/services/url.service'; import { toError } from '@/utils'; +const REVOKED_AT_FORMATTER = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', +}); + +function formatRevokedAt(date: Date): string { + return REVOKED_AT_FORMATTER.format(date); +} + +function formatRevokedBy(user: { + firstName?: string | null; + lastName?: string | null; + email: string; +}): string { + const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + return fullName || user.email; +} + type Template = HandlebarsTemplateDelegate; type TemplateName = | 'user-invited' @@ -27,7 +46,8 @@ type TemplateName = | 'workflow-shared' | 'credentials-shared' | 'project-shared' - | 'workflow-failure'; + | 'workflow-failure' + | 'api-key-revoked'; @Service() export class UserManagementMailer { @@ -78,6 +98,34 @@ export class UserManagementMailer { }); } + async notifyApiKeyRevoked({ + apiKey, + revoker, + }: { + apiKey: ApiKey; + revoker: User; + }): Promise { + if (!this.mailer) return { emailSent: false }; + + const baseUrl = this.urlService.getInstanceBaseUrl(); + const template = await this.getTemplate('api-key-revoked'); + + return await this.mailer.sendMail({ + emailRecipients: apiKey.user.email, + subject: 'Your n8n API key was revoked', + body: template({ + ...this.basePayload, + email: apiKey.user.email, + firstName: apiKey.user.firstName ?? 'there', + label: apiKey.label, + suffix: apiKey.apiKey.slice(-4), + revokedBy: formatRevokedBy(revoker), + revokedAt: formatRevokedAt(new Date()), + createApiKeyUrl: `${baseUrl}/settings/api`, + }), + }); + } + async workflowFailure(data: { email: string; firstName?: string;