feat(core): Email public API key owners when their key is revoked by an admin (#32086)

This commit is contained in:
Ricardo Espinoza
2026-06-12 11:36:18 -04:00
committed by GitHub
parent 93d9387f3e
commit ac197878d9
8 changed files with 252 additions and 9 deletions
@@ -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']);
+1
View File
@@ -159,6 +159,7 @@ describe('GlobalConfig', () => {
'workflow-failure': '',
'workflow-shared': '',
'project-shared': '',
'api-key-revoked': '',
},
},
},
@@ -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 () => {
@@ -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<ApiKeyRepository>();
const jwtService = mock<JwtService>();
const mailer = mock<UserManagementMailer>();
const logger = mock<Logger>();
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<ApiKey>({
id: 'key-1',
userId: 'owner-1',
user: mock<User>({ id: 'owner-1' }),
});
const owner = mock<User>({ id: 'owner-1' });
const admin = mock<User>({ 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 users 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();
});
});
});
@@ -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) {
@@ -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<ApiKey>({
id: 'key-1',
label: 'Test 123',
apiKey: 'n8n_api_xxxxxxxaaa5',
userId: 'owner-1',
user: mock<User>({
id: 'owner-1',
email: 'owner@example.com',
firstName: 'Maria',
lastName: 'Silva',
}),
});
const revoker = mock<User>({
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<ApiKey>({
id: 'key-1',
label: 'Test 123',
apiKey: 'n8n_api_xxxxxxxaaa5',
userId: 'owner-1',
user: mock<User>({
id: 'owner-1',
email: 'owner@example.com',
firstName: 'Maria',
}),
});
const revoker = mock<User>({
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<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
const newSharees = [
@@ -0,0 +1,19 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section padding="0 20px">
<mj-column>
<mj-include path="./_logo.mjml" />
<mj-text font-size="22px" font-weight="400">Your API key was revoked</mj-text>
<mj-text>
Your API key <b>"{{label}}"</b> (••••{{suffix}}) on <b>{{domain}}</b> was revoked by <b>{{revokedBy}}</b> on {{revokedAt}}.
</mj-text>
<mj-text>
Any integrations or scripts still using this key have stopped working. If you still need API access, you can create a new key.
</mj-text>
<mj-button href="{{createApiKeyUrl}}">Create new API key</mj-button>
<mj-include path="./_footer.mjml" />
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -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<unknown>;
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<SendEmailResult> {
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;