mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Email public API key owners when their key is revoked by an admin (#32086)
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user