From a13a4f74425559f5caff4d696ab2b00486295fee Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 26 Sep 2024 08:58:49 -0400 Subject: [PATCH] refactor: Move API keys into their own table (no-changelog) (#10629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .../__tests__/me.controller.test.ts | 59 +++++- packages/cli/src/controllers/me.controller.ts | 43 ++-- .../entities/__tests__/user.entity.test.ts | 1 - .../cli/src/databases/entities/api-key.ts | 25 +++ packages/cli/src/databases/entities/index.ts | 2 + packages/cli/src/databases/entities/user.ts | 10 +- .../common/1724951148974-AddApiKeysTable.ts | 58 ++++++ .../src/databases/migrations/mysqldb/index.ts | 2 + .../databases/migrations/postgresdb/index.ts | 2 + .../sqlite/1724951148974-AddApiKeysTable.ts | 77 +++++++ .../src/databases/migrations/sqlite/index.ts | 2 + .../repositories/api-key.repository.ts | 11 + packages/cli/src/public-api/index.ts | 9 +- packages/cli/src/requests.ts | 1 + .../src/services/public-api-key.service.ts | 80 ++++++++ packages/cli/src/services/user.service.ts | 2 +- .../controllers/invitation/assertions.ts | 1 - packages/cli/test/integration/me.api.test.ts | 188 ++++++++++-------- .../public-api/credentials.test.ts | 13 +- .../integration/public-api/executions.test.ts | 9 +- .../integration/public-api/projects.test.ts | 48 ++--- .../test/integration/public-api/tags.test.ts | 14 +- .../integration/public-api/users.ee.test.ts | 74 +++---- .../test/integration/public-api/users.test.ts | 39 ++-- .../integration/public-api/variables.test.ts | 15 +- .../integration/public-api/workflows.test.ts | 14 +- .../cli/test/integration/shared/db/users.ts | 44 ++-- .../cli/test/integration/shared/test-db.ts | 1 + packages/cli/test/integration/shared/types.ts | 2 + .../integration/shared/utils/test-server.ts | 29 ++- packages/editor-ui/src/Interface.ts | 8 + packages/editor-ui/src/api/api-keys.ts | 17 +- .../src/plugins/i18n/locales/en.json | 1 - .../editor-ui/src/stores/settings.store.ts | 14 +- .../editor-ui/src/views/SettingsApiView.vue | 27 +-- 35 files changed, 630 insertions(+), 312 deletions(-) create mode 100644 packages/cli/src/databases/entities/api-key.ts create mode 100644 packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts create mode 100644 packages/cli/src/databases/repositories/api-key.repository.ts create mode 100644 packages/cli/src/services/public-api-key.service.ts diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index 3c9af48689..7f5f861b0e 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types'; import type { Response } from 'express'; import { mock, anyObject } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; +import { randomString } from 'n8n-workflow'; import { Container } from 'typedi'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { API_KEY_PREFIX, MeController } from '@/controllers/me.controller'; +import { MeController } from '@/controllers/me.controller'; +import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import type { AuthenticatedRequest, MeRequest } from '@/requests'; +import { API_KEY_PREFIX } from '@/services/public-api-key.service'; import { UserService } from '@/services/user.service'; import { mockInstance } from '@test/mocking'; import { badPasswords } from '@test/test-data'; @@ -30,6 +34,7 @@ describe('MeController', () => { const userService = mockInstance(UserService); const userRepository = mockInstance(UserRepository); const mockMfaService = mockInstance(MfaService); + const apiKeysRepository = mockInstance(ApiKeyRepository); mockInstance(AuthUserRepository); mockInstance(InvalidAuthTokenRepository); mockInstance(License).isWithinUsersLimit.mockReturnValue(true); @@ -412,27 +417,63 @@ describe('MeController', () => { describe('API Key methods', () => { let req: AuthenticatedRequest; beforeAll(() => { - req = mock({ user: mock>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) }); + req = mock({ user: mock({ id: '123' }) }); }); describe('createAPIKey', () => { it('should create and save an API key', async () => { - const { apiKey } = await controller.createAPIKey(req); - expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey }); + const apiKeyData = { + id: '123', + userId: '123', + label: 'My API Key', + apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + createdAt: new Date(), + } as ApiKey; + + apiKeysRepository.upsert.mockImplementation(); + + apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData); + + const newApiKey = await controller.createAPIKey(req); + + expect(apiKeysRepository.upsert).toHaveBeenCalled(); + expect(apiKeyData).toEqual(newApiKey); }); }); - describe('getAPIKey', () => { - it('should return the users api key redacted', async () => { - const { apiKey } = await controller.getAPIKey(req); - expect(apiKey).not.toEqual(req.user.apiKey); + describe('getAPIKeys', () => { + it('should return the users api keys redacted', async () => { + const apiKeyData = { + id: '123', + userId: '123', + label: 'My API Key', + apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + createdAt: new Date(), + } as ApiKey; + + apiKeysRepository.findBy.mockResolvedValue([apiKeyData]); + + const apiKeys = await controller.getAPIKeys(req); + expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey); + expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id }); }); }); describe('deleteAPIKey', () => { it('should delete the API key', async () => { + const user = mock({ + id: '123', + password: 'password', + authIdentities: [], + role: 'global:member', + mfaEnabled: false, + }); + const req = mock({ user, params: { id: user.id } }); await controller.deleteAPIKey(req); - expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey: null }); + expect(apiKeysRepository.delete).toHaveBeenCalledWith({ + userId: req.user.id, + id: req.params.id, + }); }); }); }); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 0e1bbb37a6..aac1b48833 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -4,7 +4,6 @@ import { UserUpdateRequestDto, } from '@n8n/api-types'; import { plainToInstance } from 'class-transformer'; -import { randomBytes } from 'crypto'; import { type RequestHandler, Response } from 'express'; import { AuthService } from '@/auth/auth.service'; @@ -22,13 +21,12 @@ import { MfaService } from '@/mfa/mfa.service'; import { isApiEnabled } from '@/public-api'; import { AuthenticatedRequest, MeRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { UserService } from '@/services/user.service'; import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; -export const API_KEY_PREFIX = 'n8n_api_'; - export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { if (isApiEnabled()) { next(); @@ -48,6 +46,7 @@ export class MeController { private readonly userRepository: UserRepository, private readonly eventService: EventService, private readonly mfaService: MfaService, + private readonly publicApiKeyService: PublicApiKeyService, ) {} /** @@ -219,34 +218,32 @@ export class MeController { } /** - * Creates an API Key + * Create an API Key */ - @Post('/api-key', { middlewares: [isApiEnabledMiddleware] }) + @Post('/api-keys', { middlewares: [isApiEnabledMiddleware] }) async createAPIKey(req: AuthenticatedRequest) { - const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; - - await this.userService.update(req.user.id, { apiKey }); + const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user); this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); - return { apiKey }; + return newApiKey; } /** - * Get an API Key + * Get API keys */ - @Get('/api-key', { middlewares: [isApiEnabledMiddleware] }) - async getAPIKey(req: AuthenticatedRequest) { - const apiKey = this.redactApiKey(req.user.apiKey); - return { apiKey }; + @Get('/api-keys', { middlewares: [isApiEnabledMiddleware] }) + async getAPIKeys(req: AuthenticatedRequest) { + const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user); + return apiKeys; } /** - * Deletes an API Key + * Delete an API Key */ - @Delete('/api-key', { middlewares: [isApiEnabledMiddleware] }) - async deleteAPIKey(req: AuthenticatedRequest) { - await this.userService.update(req.user.id, { apiKey: null }); + @Delete('/api-keys/:id', { middlewares: [isApiEnabledMiddleware] }) + async deleteAPIKey(req: MeRequest.DeleteAPIKey) { + await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id); this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false }); @@ -273,14 +270,4 @@ export class MeController { return user.settings; } - - private redactApiKey(apiKey: string | null) { - if (!apiKey) return; - const keepLength = 5; - return ( - API_KEY_PREFIX + - apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + - '*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength) - ); - } } diff --git a/packages/cli/src/databases/entities/__tests__/user.entity.test.ts b/packages/cli/src/databases/entities/__tests__/user.entity.test.ts index 5901d0218b..5bd8b0f2cb 100644 --- a/packages/cli/src/databases/entities/__tests__/user.entity.test.ts +++ b/packages/cli/src/databases/entities/__tests__/user.entity.test.ts @@ -8,7 +8,6 @@ describe('User Entity', () => { firstName: 'Don', lastName: 'Joe', password: '123456789', - apiKey: '123', }); expect(JSON.stringify(user)).toEqual( '{"email":"test@example.com","firstName":"Don","lastName":"Joe"}', diff --git a/packages/cli/src/databases/entities/api-key.ts b/packages/cli/src/databases/entities/api-key.ts new file mode 100644 index 0000000000..6e2df2d00c --- /dev/null +++ b/packages/cli/src/databases/entities/api-key.ts @@ -0,0 +1,25 @@ +import { Column, Entity, Index, ManyToOne, Unique } from '@n8n/typeorm'; + +import { WithTimestampsAndStringId } from './abstract-entity'; +import { User } from './user'; + +@Entity('user_api_keys') +@Unique(['userId', 'label']) +export class ApiKey extends WithTimestampsAndStringId { + @ManyToOne( + () => User, + (user) => user.id, + { onDelete: 'CASCADE' }, + ) + user: User; + + @Column({ type: String }) + userId: string; + + @Column({ type: String }) + label: string; + + @Index({ unique: true }) + @Column({ type: String }) + apiKey: string; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 383ad4084d..1993d20a75 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -1,5 +1,6 @@ import { AnnotationTagEntity } from './annotation-tag-entity.ee'; import { AnnotationTagMapping } from './annotation-tag-mapping.ee'; +import { ApiKey } from './api-key'; import { AuthIdentity } from './auth-identity'; import { AuthProviderSyncHistory } from './auth-provider-sync-history'; import { AuthUser } from './auth-user'; @@ -54,4 +55,5 @@ export const entities = { WorkflowHistory, Project, ProjectRelation, + ApiKey, }; diff --git a/packages/cli/src/databases/entities/user.ts b/packages/cli/src/databases/entities/user.ts index 32b257fcd1..b75bec757c 100644 --- a/packages/cli/src/databases/entities/user.ts +++ b/packages/cli/src/databases/entities/user.ts @@ -23,6 +23,7 @@ import { NoUrl } from '@/validators/no-url.validator'; import { NoXss } from '@/validators/no-xss.validator'; import { WithTimestamps, jsonColumnType } from './abstract-entity'; +import type { ApiKey } from './api-key'; import type { AuthIdentity } from './auth-identity'; import type { ProjectRelation } from './project-relation'; import type { SharedCredentials } from './shared-credentials'; @@ -89,6 +90,9 @@ export class User extends WithTimestamps implements IUser { @OneToMany('AuthIdentity', 'user') authIdentities: AuthIdentity[]; + @OneToMany('ApiKey', 'user') + apiKeys: ApiKey[]; + @OneToMany('SharedWorkflow', 'user') sharedWorkflows: SharedWorkflow[]; @@ -107,10 +111,6 @@ export class User extends WithTimestamps implements IUser { this.email = this.email?.toLowerCase() ?? null; } - @Column({ type: String, nullable: true }) - @Index({ unique: true }) - apiKey: string | null; - @Column({ type: Boolean, default: false }) mfaEnabled: boolean; @@ -151,7 +151,7 @@ export class User extends WithTimestamps implements IUser { } toJSON() { - const { password, apiKey, ...rest } = this; + const { password, ...rest } = this; return rest; } diff --git a/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts b/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts new file mode 100644 index 0000000000..547be1a8fb --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts @@ -0,0 +1,58 @@ +import type { ApiKey } from '@/databases/entities/api-key'; +import type { MigrationContext } from '@/databases/types'; +import { generateNanoId } from '@/databases/utils/generators'; + +export class AddApiKeysTable1724951148974 { + async up({ + queryRunner, + escape, + runQuery, + schemaBuilder: { createTable, column }, + }: MigrationContext) { + const userTable = escape.tableName('user'); + const userApiKeysTable = escape.tableName('user_api_keys'); + const userIdColumn = escape.columnName('userId'); + const apiKeyColumn = escape.columnName('apiKey'); + const labelColumn = escape.columnName('label'); + const idColumn = escape.columnName('id'); + + // Create the new table + await createTable('user_api_keys') + .withColumns( + column('id').varchar(36).primary, + column('userId').uuid.notNull, + column('label').varchar(100).notNull, + column('apiKey').varchar().notNull, + ) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn(['userId', 'label'], true) + .withIndexOn(['apiKey'], true).withTimestamps; + + const usersWithApiKeys = (await queryRunner.query( + `SELECT ${idColumn}, ${apiKeyColumn} FROM ${userTable} WHERE ${apiKeyColumn} IS NOT NULL`, + )) as Array>; + + // Move the apiKey from the users table to the new table + await Promise.all( + usersWithApiKeys.map( + async (user: { id: string; apiKey: string }) => + await runQuery( + `INSERT INTO ${userApiKeysTable} (${idColumn}, ${userIdColumn}, ${apiKeyColumn}, ${labelColumn}) VALUES (:id, :userId, :apiKey, :label)`, + { + id: generateNanoId(), + userId: user.id, + apiKey: user.apiKey, + label: 'My API Key', + }, + ), + ), + ); + + // Drop apiKey column on user's table + await queryRunner.query(`ALTER TABLE ${userTable} DROP COLUMN ${apiKeyColumn};`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index f3660f905d..288f18edbe 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101 import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; +import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index e3ad5afa57..077d686b7e 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101 import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; +import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -128,4 +129,5 @@ export const postgresMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts b/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts new file mode 100644 index 0000000000..19d27aeb85 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts @@ -0,0 +1,77 @@ +import type { ApiKey } from '@/databases/entities/api-key'; +import type { MigrationContext } from '@/databases/types'; +import { generateNanoId } from '@/databases/utils/generators'; + +export class AddApiKeysTable1724951148974 { + async up({ queryRunner, tablePrefix, runQuery }: MigrationContext) { + const tableName = `${tablePrefix}user_api_keys`; + + // Create the table + await queryRunner.query(` + CREATE TABLE ${tableName} ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + "userId" VARCHAR NOT NULL, + "label" VARCHAR(100) NOT NULL, + "apiKey" VARCHAR NOT NULL, + "createdAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + "updatedAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY ("userId") REFERENCES user(id) ON DELETE CASCADE, + UNIQUE ("userId", label), + UNIQUE("apiKey") + ); + `); + + const usersWithApiKeys = (await queryRunner.query( + `SELECT id, "apiKey" FROM ${tablePrefix}user WHERE "apiKey" IS NOT NULL`, + )) as Array>; + + // Move the apiKey from the users table to the new table + await Promise.all( + usersWithApiKeys.map( + async (user: { id: string; apiKey: string }) => + await runQuery( + `INSERT INTO ${tableName} ("id", "userId", "apiKey", "label") VALUES (:id, :userId, :apiKey, :label)`, + { + id: generateNanoId(), + userId: user.id, + apiKey: user.apiKey, + label: 'My API Key', + }, + ), + ), + ); + + // Create temporary table to store the users dropping the api key column + await queryRunner.query(` + CREATE TABLE users_new ( + id varchar PRIMARY KEY, + email VARCHAR(255) UNIQUE, + "firstName" VARCHAR(32), + "lastName" VARCHAR(32), + password VARCHAR, + "personalizationAnswers" TEXT, + "createdAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + "updatedAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + settings TEXT, + disabled BOOLEAN DEFAULT FALSE NOT NULL, + "mfaEnabled" BOOLEAN DEFAULT FALSE NOT NULL, + "mfaSecret" TEXT, + "mfaRecoveryCodes" TEXT, + role TEXT NOT NULL + ); + `); + + // Copy the data from the original users table + await queryRunner.query(` + INSERT INTO users_new ("id", "email", "firstName", "lastName", "password", "personalizationAnswers", "createdAt", "updatedAt", "settings", "disabled", "mfaEnabled", "mfaSecret", "mfaRecoveryCodes", "role") + SELECT "id", "email", "firstName", "lastName", "password", "personalizationAnswers", "createdAt", "updatedAt", "settings", "disabled", "mfaEnabled", "mfaSecret", "mfaRecoveryCodes", "role" + FROM ${tablePrefix}user; + `); + + // Drop table with apiKey column + await queryRunner.query(`DROP TABLE ${tablePrefix}user;`); + + // Rename the temporary table to users + await queryRunner.query('ALTER TABLE users_new RENAME TO user;'); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index a1739ae4a4..62fda4b7d0 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -37,6 +37,7 @@ import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns'; import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; +import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/api-key.repository.ts b/packages/cli/src/databases/repositories/api-key.repository.ts new file mode 100644 index 0000000000..21ad2c3e40 --- /dev/null +++ b/packages/cli/src/databases/repositories/api-key.repository.ts @@ -0,0 +1,11 @@ +import { DataSource, Repository } from '@n8n/typeorm'; +import { Service } from 'typedi'; + +import { ApiKey } from '../entities/api-key'; + +@Service() +export class ApiKeyRepository extends Repository { + constructor(dataSource: DataSource) { + super(ApiKey, dataSource.manager); + } +} diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index c240a3efa3..1264f57496 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -10,10 +10,10 @@ import { Container } from 'typedi'; import validator from 'validator'; import YAML from 'yamljs'; -import { UserRepository } from '@/databases/repositories/user.repository'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; import type { AuthenticatedRequest } from '@/requests'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { UrlService } from '@/services/url.service'; async function createApiRouter( @@ -90,10 +90,9 @@ async function createApiRouter( _scopes: unknown, schema: OpenAPIV3.ApiKeySecurityScheme, ): Promise => { - const apiKey = req.headers[schema.name.toLowerCase()] as string; - const user = await Container.get(UserRepository).findOne({ - where: { apiKey }, - }); + const providedApiKey = req.headers[schema.name.toLowerCase()] as string; + + const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey); if (!user) return false; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 5afe97f31a..ab4c32ad19 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -186,6 +186,7 @@ export declare namespace CredentialRequest { export declare namespace MeRequest { export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; + export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>; } export interface UserSetupPayload { diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts new file mode 100644 index 0000000000..e689f3c019 --- /dev/null +++ b/packages/cli/src/services/public-api-key.service.ts @@ -0,0 +1,80 @@ +import { randomBytes } from 'node:crypto'; +import Container, { Service } from 'typedi'; + +import { ApiKey } from '@/databases/entities/api-key'; +import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; + +export const API_KEY_PREFIX = 'n8n_api_'; + +@Service() +export class PublicApiKeyService { + constructor(private readonly apiKeyRepository: ApiKeyRepository) {} + + /** + * Creates a new public API key for the specified user. + * @param user - The user for whom the API key is being created. + * @returns A promise that resolves to the newly created API key. + */ + async createPublicApiKeyForUser(user: User) { + const apiKey = this.createApiKeyString(); + await this.apiKeyRepository.upsert( + this.apiKeyRepository.create({ + userId: user.id, + apiKey, + label: 'My API Key', + }), + ['apiKey'], + ); + + return await this.apiKeyRepository.findOneByOrFail({ apiKey }); + } + + /** + * Retrieves and redacts API keys for a given user. + * @param user - The user for whom to retrieve and redact API keys. + * @returns A promise that resolves to an array of objects containing redacted API keys. + */ + async getRedactedApiKeysForUser(user: User) { + const apiKeys = await this.apiKeyRepository.findBy({ userId: user.id }); + return apiKeys.map((apiKeyRecord) => ({ + ...apiKeyRecord, + apiKey: this.redactApiKey(apiKeyRecord.apiKey), + })); + } + + async deleteApiKeyForUser(user: User, apiKeyId: string) { + await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); + } + + async getUserForApiKey(apiKey: string) { + return await Container.get(UserRepository) + .createQueryBuilder('user') + .innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id') + .where('apiKey.apiKey = :apiKey', { apiKey }) + .select('user') + .getOne(); + } + + /** + * Redacts an API key by keeping the first few characters and replacing the rest with asterisks. + * @param apiKey - The API key to be redacted. If null, the function returns undefined. + * @returns The redacted API key with a fixed prefix and asterisks replacing the rest of the characters. + * @example + * ```typescript + * const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890'); + * console.log(redactedKey); // Output: '12345-*****' + * ``` + */ + redactApiKey(apiKey: string) { + const keepLength = 5; + return ( + API_KEY_PREFIX + + apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + + '*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength) + ); + } + + createApiKeyString = () => `${API_KEY_PREFIX}${randomBytes(40).toString('hex')}`; +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 259a30666c..3d8fde6c00 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -58,7 +58,7 @@ export class UserService { withScopes?: boolean; }, ) { - const { password, updatedAt, apiKey, authIdentities, ...rest } = user; + const { password, updatedAt, authIdentities, ...rest } = user; const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); diff --git a/packages/cli/test/integration/controllers/invitation/assertions.ts b/packages/cli/test/integration/controllers/invitation/assertions.ts index 3e24a53222..daa40586f2 100644 --- a/packages/cli/test/integration/controllers/invitation/assertions.ts +++ b/packages/cli/test/integration/controllers/invitation/assertions.ts @@ -10,7 +10,6 @@ export function assertReturnedUserProps(user: User) { expect(user.personalizationAnswers).toBeNull(); expect(user.password).toBeUndefined(); expect(user.isPending).toBe(false); - expect(user.apiKey).not.toBeDefined(); expect(user.globalScopes).toBeDefined(); expect(user.globalScopes).not.toHaveLength(0); } diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index df9b7c48b6..2fc9b07870 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,22 +1,29 @@ import { GlobalConfig } from '@n8n/config'; -import { IsNull } from '@n8n/typeorm'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; import { Container } from 'typedi'; import validator from 'validator'; +import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { mockInstance } from '@test/mocking'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; -import { addApiKey, createOwner, createUser, createUserShell } from './shared/db/users'; -import { randomApiKey, randomEmail, randomName, randomValidPassword } from './shared/random'; +import { createOwnerWithApiKey, createUser, createUserShell } from './shared/db/users'; +import { randomEmail, randomName, randomValidPassword } from './shared/random'; import * as testDb from './shared/test-db'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); +let publicApiKeyService: PublicApiKeyService; + +beforeAll(() => { + publicApiKeyService = Container.get(PublicApiKeyService); +}); beforeEach(async () => { await testDb.truncate(['User']); @@ -28,22 +35,22 @@ describe('When public API is disabled', () => { let authAgent: SuperAgentTest; beforeEach(async () => { - owner = await createOwner(); - await addApiKey(owner); + owner = await createOwnerWithApiKey(); + authAgent = testServer.authAgentFor(owner); mockInstance(GlobalConfig, { publicApi: { disabled: true } }); }); - test('POST /me/api-key should 404', async () => { - await authAgent.post('/me/api-key').expect(404); + test('POST /me/api-keys should 404', async () => { + await authAgent.post('/me/api-keys').expect(404); }); - test('GET /me/api-key should 404', async () => { - await authAgent.get('/me/api-key').expect(404); + test('GET /me/api-keys should 404', async () => { + await authAgent.get('/me/api-keys').expect(404); }); - test('DELETE /me/api-key should 404', async () => { - await authAgent.delete('/me/api-key').expect(404); + test('DELETE /me/api-key/:id should 404', async () => { + await authAgent.delete(`/me/api-keys/${1}`).expect(404); }); }); @@ -53,7 +60,6 @@ describe('Owner shell', () => { beforeEach(async () => { ownerShell = await createUserShell('global:owner'); - await addApiKey(ownerShell); authOwnerShellAgent = testServer.authAgentFor(ownerShell); }); @@ -63,17 +69,8 @@ describe('Owner shell', () => { expect(response.statusCode).toBe(200); - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - role, - password, - isPending, - apiKey, - } = response.body.data; + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(validPayload.email.toLowerCase()); @@ -83,7 +80,6 @@ describe('Owner shell', () => { expect(password).toBeUndefined(); expect(isPending).toBe(false); expect(role).toBe('global:owner'); - expect(apiKey).toBeUndefined(); const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -161,37 +157,56 @@ describe('Owner shell', () => { } }); - test('POST /me/api-key should create an api key', async () => { - const response = await authOwnerShellAgent.post('/me/api-key'); + test('POST /me/api-keys should create an api key', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).toBeDefined(); - expect(response.body.data.apiKey).not.toBeNull(); + const newApiKey = newApiKeyResponse.body.data as ApiKey; - const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ - where: { email: IsNull() }, + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKey).toBeDefined(); + + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: ownerShell.id, }); - expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey); - }); - - test('GET /me/api-key should fetch the api key redacted', async () => { - const response = await authOwnerShellAgent.get('/me/api-key'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).not.toEqual(ownerShell.apiKey); - }); - - test('DELETE /me/api-key should delete the api key', async () => { - const response = await authOwnerShellAgent.delete('/me/api-key'); - - expect(response.statusCode).toBe(200); - - const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ - where: { email: IsNull() }, + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: ownerShell.id, + apiKey: newApiKey.apiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), }); + }); - expect(storedShellOwner.apiKey).toBeNull(); + test('GET /me/api-keys should fetch the api key redacted', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); + + const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys'); + + expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: newApiKeyResponse.body.data.id, + label: 'My API Key', + userId: ownerShell.id, + apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + test('DELETE /me/api-keys/:id should delete the api key', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); + + const deleteApiKeyResponse = await authOwnerShellAgent.delete( + `/me/api-keys/${newApiKeyResponse.body.data.id}`, + ); + + const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys'); + + expect(deleteApiKeyResponse.body.data.success).toBe(true); + expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); }); }); @@ -204,10 +219,8 @@ describe('Member', () => { member = await createUser({ password: memberPassword, role: 'global:member', - apiKey: randomApiKey(), }); authMemberAgent = testServer.authAgentFor(member); - await utils.setInstanceOwnerSetUp(true); }); @@ -215,17 +228,8 @@ describe('Member', () => { for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - role, - password, - isPending, - apiKey, - } = response.body.data; + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(validPayload.email.toLowerCase()); @@ -235,7 +239,6 @@ describe('Member', () => { expect(password).toBeUndefined(); expect(isPending).toBe(false); expect(role).toBe('global:member'); - expect(apiKey).toBeUndefined(); const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -275,6 +278,7 @@ describe('Member', () => { }; const response = await authMemberAgent.patch('/me/password').send(validPayload); + expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); @@ -315,33 +319,59 @@ describe('Member', () => { } }); - test('POST /me/api-key should create an api key', async () => { - const response = await testServer.authAgentFor(member).post('/me/api-key'); + test('POST /me/api-keys should create an api key', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).toBeDefined(); - expect(response.body.data.apiKey).not.toBeNull(); + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); + expect(newApiKeyResponse.body.data.apiKey).not.toBeNull(); - const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id }); + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: member.id, + }); - expect(storedMember.apiKey).toEqual(response.body.data.apiKey); + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: member.id, + apiKey: newApiKeyResponse.body.data.apiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); }); - test('GET /me/api-key should fetch the api key redacted', async () => { - const response = await testServer.authAgentFor(member).get('/me/api-key'); + test('GET /me/api-keys should fetch the api key redacted', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).not.toEqual(member.apiKey); + const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys'); + + expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: newApiKeyResponse.body.data.id, + label: 'My API Key', + userId: member.id, + apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + + expect(newApiKeyResponse.body.data.apiKey).not.toEqual( + retrieveAllApiKeysResponse.body.data[0].apiKey, + ); }); - test('DELETE /me/api-key should delete the api key', async () => { - const response = await testServer.authAgentFor(member).delete('/me/api-key'); + test('DELETE /me/api-keys/:id should delete the api key', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); + const deleteApiKeyResponse = await testServer + .authAgentFor(member) + .delete(`/me/api-keys/${newApiKeyResponse.body.data.id}`); - const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id }); + const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys'); - expect(storedMember.apiKey).toBeNull(); + expect(deleteApiKeyResponse.body.data.success).toBe(true); + expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); }); }); diff --git a/packages/cli/test/integration/public-api/credentials.test.ts b/packages/cli/test/integration/public-api/credentials.test.ts index 7323de391e..5574d4f3bf 100644 --- a/packages/cli/test/integration/public-api/credentials.test.ts +++ b/packages/cli/test/integration/public-api/credentials.test.ts @@ -7,8 +7,8 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre import { createTeamProject } from '@test-integration/db/projects'; import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; -import { addApiKey, createUser, createUserShell } from '../shared/db/users'; -import { randomApiKey, randomName } from '../shared/random'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; +import { randomName } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import type { SuperAgentTest } from '../shared/types'; @@ -24,8 +24,8 @@ let saveCredential: SaveCredentialFunction; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await addApiKey(await createUserShell('global:owner')); - member = await createUser({ role: 'global:member', apiKey: randomApiKey() }); + owner = await createOwnerWithApiKey(); + member = await createMemberWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); authMemberAgent = testServer.publicApiAgentFor(member); @@ -156,10 +156,7 @@ describe('DELETE /credentials/:id', () => { }); test('should delete owned cred for member but leave others untouched', async () => { - const anotherMember = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + const anotherMember = await createMemberWithApiKey(); const savedCredential = await saveCredential(dbCredential(), { user: member }); const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member }); diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 019f69adc5..13324ed55c 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -12,13 +12,12 @@ import { createSuccessfulExecution, createWaitingExecution, } from '../shared/db/executions'; -import { createUser } from '../shared/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import { createManyWorkflows, createWorkflow, shareWorkflowWithUsers, } from '../shared/db/workflows'; -import { randomApiKey } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -36,9 +35,9 @@ mockInstance(Telemetry); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() }); - user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); - user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); + owner = await createOwnerWithApiKey(); + user1 = await createMemberWithApiKey(); + user2 = await createMemberWithApiKey(); // TODO: mock BinaryDataService instead await utils.initBinaryDataService(); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 2bc8e9346b..f815d9d07b 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -2,7 +2,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; -import { createMember, createOwner } from '@test-integration/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -26,7 +26,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projects = await Promise.all([ createTeamProject(), createTeamProject(), @@ -53,15 +53,10 @@ describe('Projects in Public API', () => { }); it('if not authenticated, should reject', async () => { - /** - * Arrange - */ - const owner = await createOwner({ withApiKey: false }); - /** * Act */ - const response = await testServer.publicApiAgentFor(owner).get('/projects'); + const response = await testServer.publicApiAgentWithoutApiKey().get('/projects'); /** * Assert @@ -74,7 +69,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); /** * Act @@ -97,12 +92,12 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).get('/projects'); + const response = await testServer.publicApiAgentFor(member).get('/projects'); /** * Assert @@ -119,7 +114,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -150,14 +145,13 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const projectPayload = { name: 'some-project' }; /** * Act */ const response = await testServer - .publicApiAgentFor(owner) + .publicApiAgentWithoutApiKey() .post('/projects') .send(projectPayload); @@ -172,7 +166,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -199,7 +193,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -225,7 +219,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -244,13 +238,14 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const project = await createTeamProject(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + const response = await testServer + .publicApiAgentWithoutApiKey() + .delete(`/projects/${project.id}`); /** * Assert @@ -263,7 +258,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -287,13 +282,13 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const owner = await createMemberWithApiKey(); const project = await createTeamProject(); /** * Act */ - const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); /** * Assert @@ -310,7 +305,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject('old-name'); /** @@ -332,14 +327,13 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const project = await createTeamProject(); /** * Act */ const response = await testServer - .publicApiAgentFor(owner) + .publicApiAgentWithoutApiKey() .put(`/projects/${project.id}`) .send({ name: 'new-name' }); @@ -354,7 +348,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -381,7 +375,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const project = await createTeamProject(); /** diff --git a/packages/cli/test/integration/public-api/tags.test.ts b/packages/cli/test/integration/public-api/tags.test.ts index 776d79d368..c2e25cc3f6 100644 --- a/packages/cli/test/integration/public-api/tags.test.ts +++ b/packages/cli/test/integration/public-api/tags.test.ts @@ -4,8 +4,7 @@ import type { User } from '@/databases/entities/user'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { createTag } from '../shared/db/tags'; -import { createUser } from '../shared/db/users'; -import { randomApiKey } from '../shared/random'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -18,15 +17,8 @@ let authMemberAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - - member = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + owner = await createOwnerWithApiKey(); + member = await createMemberWithApiKey(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/public-api/users.ee.test.ts b/packages/cli/test/integration/public-api/users.ee.test.ts index 04649403d9..08161a41d8 100644 --- a/packages/cli/test/integration/public-api/users.ee.test.ts +++ b/packages/cli/test/integration/public-api/users.ee.test.ts @@ -6,8 +6,13 @@ import { License } from '@/license'; import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; import { mockInstance } from '../../shared/mocking'; -import { createOwner, createUser, createUserShell } from '../shared/db/users'; -import { randomApiKey } from '../shared/random'; +import { + createMember, + createMemberWithApiKey, + createOwnerWithApiKey, + createUser, + createUserShell, +} from '../shared/db/users'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -25,32 +30,23 @@ beforeEach(async () => { describe('With license unlimited quota:users', () => { describe('GET /users', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ role: 'global:owner' }); - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const authOwnerAgent = testServer.publicApiAgentWithoutApiKey(); await authOwnerAgent.get('/users').expect(401); }); test('should fail due to invalid API Key', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - owner.apiKey = 'invalid-key'; - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key'); await authOwnerAgent.get('/users').expect(401); }); test('should fail due to member trying to access owner only endpoint', async () => { - const member = await createUser({ apiKey: randomApiKey() }); + const member = await createMemberWithApiKey(); const authMemberAgent = testServer.publicApiAgentFor(member); await authMemberAgent.get('/users').expect(403); }); test('should return all users', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); @@ -92,10 +88,10 @@ describe('With license unlimited quota:users', () => { * Arrange */ const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ - createOwner({ withApiKey: true }), - createUser({ role: 'global:member' }), - createUser({ role: 'global:member' }), - createUser({ role: 'global:member' }), + createOwnerWithApiKey(), + createMember(), + createMember(), + createMember(), ]); const [firstProject, secondProject] = await Promise.all([ @@ -130,40 +126,30 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:id', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ role: 'global:owner' }); - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const owner = await createOwnerWithApiKey(); + const authOwnerAgent = testServer.publicApiAgentWithoutApiKey(); await authOwnerAgent.get(`/users/${owner.id}`).expect(401); }); test('should fail due to invalid API Key', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - owner.apiKey = 'invalid-key'; - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const owner = await createOwnerWithApiKey(); + const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key'); await authOwnerAgent.get(`/users/${owner.id}`).expect(401); }); test('should fail due to member trying to access owner only endpoint', async () => { - const member = await createUser({ apiKey: randomApiKey() }); + const member = await createMemberWithApiKey(); const authMemberAgent = testServer.publicApiAgentFor(member); await authMemberAgent.get(`/users/${member.id}`).expect(403); }); test('should return 404 for non-existing id ', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get(`/users/${uuid()}`).expect(404); }); test('should return a pending user', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const { id: memberId } = await createUserShell('global:member'); @@ -199,20 +185,13 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:email', () => { test('with non-existing email should return 404', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404); }); test('should return a user', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200); @@ -249,10 +228,7 @@ describe('With license without quota:users', () => { beforeEach(async () => { mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) }); - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); }); diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index 48003d838c..0abfee9b1f 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -1,7 +1,12 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; -import { createMember, createOwner, getUserById } from '@test-integration/db/users'; +import { + createMember, + createMemberWithApiKey, + createOwnerWithApiKey, + getUserById, +} from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -23,13 +28,12 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const payload = { email: 'test@test.com', role: 'global:admin' }; /** * Act */ - const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload); /** * Assert @@ -42,7 +46,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; /** @@ -62,7 +66,8 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); + await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; /** @@ -99,13 +104,12 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const member = await createMember(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`); /** * Assert @@ -118,14 +122,14 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const firstMember = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const secondMember = await createMember(); /** * Act */ const response = await testServer - .publicApiAgentFor(firstMember) + .publicApiAgentFor(member) .delete(`/users/${secondMember.id}`); /** @@ -140,7 +144,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); /** @@ -161,13 +165,14 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const member = await createMember(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); + const response = await testServer + .publicApiAgentWithApiKey('') + .patch(`/users/${member.id}/role`); /** * Assert @@ -179,7 +184,7 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; @@ -206,7 +211,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const firstMember = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const secondMember = await createMember(); const payload = { newRoleName: 'global:admin' }; @@ -214,7 +219,7 @@ describe('Users in Public API', () => { * Act */ const response = await testServer - .publicApiAgentFor(firstMember) + .publicApiAgentFor(member) .patch(`/users/${secondMember.id}/role`) .send(payload); @@ -230,7 +235,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'invalid' }; @@ -253,7 +258,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; diff --git a/packages/cli/test/integration/public-api/variables.test.ts b/packages/cli/test/integration/public-api/variables.test.ts index c7f6ba341c..61f75d4641 100644 --- a/packages/cli/test/integration/public-api/variables.test.ts +++ b/packages/cli/test/integration/public-api/variables.test.ts @@ -1,5 +1,5 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { createOwner } from '@test-integration/db/users'; +import { createOwnerWithApiKey } from '@test-integration/db/users'; import { createVariable, getVariableOrFail } from '@test-integration/db/variables'; import { setupTestServer } from '@test-integration/utils'; @@ -22,7 +22,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variables = await Promise.all([createVariable(), createVariable(), createVariable()]); /** @@ -48,7 +48,8 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + + const owner = await createOwnerWithApiKey(); /** * Act @@ -72,7 +73,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variablePayload = { key: 'key', value: 'value' }; /** @@ -96,7 +97,7 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variablePayload = { key: 'key', value: 'value' }; /** @@ -124,7 +125,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variable = await createVariable(); /** @@ -145,7 +146,7 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variable = await createVariable(); /** diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 5bb661eaa3..11d198ff98 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects'; import { mockInstance } from '../../shared/mocking'; import { createTag } from '../shared/db/tags'; -import { createUser } from '../shared/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows'; -import { randomApiKey } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -40,18 +39,13 @@ const license = testServer.license; mockInstance(ExecutionService); beforeAll(async () => { - owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + owner = await createOwnerWithApiKey(); ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( owner.id, ); - member = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + member = await createMemberWithApiKey(); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( member.id, ); diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 83192822f9..62f9f39a05 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,8 +1,10 @@ import { hash } from 'bcryptjs'; +import { randomString } from 'n8n-workflow'; import Container from 'typedi'; import { AuthIdentity } from '@/databases/entities/auth-identity'; import { type GlobalRole, type User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -79,19 +81,38 @@ export async function createUserWithMfaEnabled( }; } -export async function createOwner({ withApiKey } = { withApiKey: false }) { - if (withApiKey) { - return await addApiKey(await createUser({ role: 'global:owner' })); - } +const createApiKeyEntity = (user: User) => { + const apiKey = randomApiKey(); + return Container.get(ApiKeyRepository).create({ + userId: user.id, + label: randomString(10), + apiKey, + }); +}; +export const addApiKey = async (user: User) => { + return await Container.get(ApiKeyRepository).save(createApiKeyEntity(user)); +}; + +export async function createOwnerWithApiKey() { + const owner = await createOwner(); + const apiKey = await addApiKey(owner); + owner.apiKeys = [apiKey]; + return owner; +} + +export async function createMemberWithApiKey() { + const member = await createMember(); + const apiKey = await addApiKey(member); + member.apiKeys = [apiKey]; + return member; +} + +export async function createOwner() { return await createUser({ role: 'global:owner' }); } -export async function createMember({ withApiKey } = { withApiKey: false }) { - if (withApiKey) { - return await addApiKey(await createUser({ role: 'global:member' })); - } - +export async function createMember() { return await createUser({ role: 'global:member' }); } @@ -128,11 +149,6 @@ export async function createManyUsers( return result.map((result) => result.user); } -export async function addApiKey(user: User): Promise { - user.apiKey = randomApiKey(); - return await Container.get(UserRepository).save(user); -} - export const getAllUsers = async () => await Container.get(UserRepository).find({ relations: ['authIdentities'], diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index f899cc7e90..0d9b1672e1 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -80,6 +80,7 @@ const repositories = [ 'WorkflowHistory', 'WorkflowStatistics', 'WorkflowTagMapping', + 'ApiKey', ] as const; /** diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 66ca2d016f..87f349fb79 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -55,6 +55,8 @@ export interface TestServer { httpServer: Server; authAgentFor: (user: User) => TestAgent; publicApiAgentFor: (user: User) => TestAgent; + publicApiAgentWithApiKey: (apiKey: string) => TestAgent; + publicApiAgentWithoutApiKey: () => TestAgent; authlessAgent: TestAgent; restlessAgent: TestAgent; license: LicenseMocker; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index e54a88fbdc..cb66b7868d 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -62,17 +62,30 @@ function createAgent( return agent; } -function publicApiAgent( +const userDoesNotHaveApiKey = (user: User) => { + return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0; +}; + +const publicApiAgent = ( app: express.Application, - { user, version = 1 }: { user: User; version?: number }, -) { + { user, apiKey, version = 1 }: { user?: User; apiKey?: string; version?: number }, +) => { + if (user && apiKey) { + throw new Error('Cannot provide both user and API key'); + } + + if (user && userDoesNotHaveApiKey(user)) { + throw new Error('User does not have an API key'); + } + + const agentApiKey = apiKey ?? user?.apiKeys[0].apiKey; + const agent = request.agent(app); void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`)); - if (user.apiKey) { - void agent.set({ 'X-N8N-API-KEY': user.apiKey }); - } + if (!user && !apiKey) return agent; + void agent.set({ 'X-N8N-API-KEY': agentApiKey }); return agent; -} +}; export const setupTestServer = ({ endpointGroups, @@ -100,6 +113,8 @@ export const setupTestServer = ({ authlessAgent: createAgent(app), restlessAgent: createAgent(app, { auth: false, noRest: true }), publicApiAgentFor: (user) => publicApiAgent(app, { user }), + publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }), + publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}), license: new LicenseMocker(), }; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index f6587e8e3b..972593bceb 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit { - return await makeRestApiRequest(context, 'GET', '/me/api-key'); +export async function getApiKeys(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/me/api-keys'); } -export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { - return await makeRestApiRequest(context, 'POST', '/me/api-key'); +export async function createApiKey(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'POST', '/me/api-keys'); } -export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> { - return await makeRestApiRequest(context, 'DELETE', '/me/api-key'); +export async function deleteApiKey( + context: IRestApiContext, + id: string, +): Promise<{ success: boolean }> { + return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`); } diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6043c23d8b..4bcad5b13f 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1766,7 +1766,6 @@ "settings.api.view.copy": "Make sure to copy your API key now as you will not be able to see this again.", "settings.api.view.info.api": "n8n API", "settings.api.view.info.webhook": "webhook node", - "settings.api.view.myKey": "My API Key", "settings.api.view.tryapi": "Try it out using the", "settings.api.view.more-details": "You can find more details in", "settings.api.view.external-docs": "the API documentation", diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 7a0cf1c2ab..a677b3e4d2 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -308,21 +308,19 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { templatesEndpointHealthy.value = true; }; - const getApiKey = async () => { + const getApiKeys = async () => { const rootStore = useRootStore(); - const { apiKey } = await publicApiApi.getApiKey(rootStore.restApiContext); - return apiKey; + return await publicApiApi.getApiKeys(rootStore.restApiContext); }; const createApiKey = async () => { const rootStore = useRootStore(); - const { apiKey } = await publicApiApi.createApiKey(rootStore.restApiContext); - return apiKey; + return await publicApiApi.createApiKey(rootStore.restApiContext); }; - const deleteApiKey = async () => { + const deleteApiKey = async (id: string) => { const rootStore = useRootStore(); - await publicApiApi.deleteApiKey(rootStore.restApiContext); + await publicApiApi.deleteApiKey(rootStore.restApiContext, id); }; const getLdapConfig = async () => { @@ -423,7 +421,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { runLdapSync, getTimezones, createApiKey, - getApiKey, + getApiKeys, deleteApiKey, testTemplatesEndpoint, submitContactInfo, diff --git a/packages/editor-ui/src/views/SettingsApiView.vue b/packages/editor-ui/src/views/SettingsApiView.vue index 7ad6781839..0278063718 100644 --- a/packages/editor-ui/src/views/SettingsApiView.vue +++ b/packages/editor-ui/src/views/SettingsApiView.vue @@ -1,6 +1,6 @@