refactor: Move API keys into their own table (no-changelog) (#10629)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ricardo Espinoza 2024-09-26 08:58:49 -04:00 committed by GitHub
parent 7e79a46750
commit a13a4f7442
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 630 additions and 312 deletions

View file

@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types';
import type { Response } from 'express'; import type { Response } from 'express';
import { mock, anyObject } from 'jest-mock-extended'; import { mock, anyObject } from 'jest-mock-extended';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { randomString } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { AUTH_COOKIE_NAME } from '@/constants'; 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 type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository'; import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service'; import { MfaService } from '@/mfa/mfa.service';
import type { AuthenticatedRequest, MeRequest } from '@/requests'; import type { AuthenticatedRequest, MeRequest } from '@/requests';
import { API_KEY_PREFIX } from '@/services/public-api-key.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { badPasswords } from '@test/test-data'; import { badPasswords } from '@test/test-data';
@ -30,6 +34,7 @@ describe('MeController', () => {
const userService = mockInstance(UserService); const userService = mockInstance(UserService);
const userRepository = mockInstance(UserRepository); const userRepository = mockInstance(UserRepository);
const mockMfaService = mockInstance(MfaService); const mockMfaService = mockInstance(MfaService);
const apiKeysRepository = mockInstance(ApiKeyRepository);
mockInstance(AuthUserRepository); mockInstance(AuthUserRepository);
mockInstance(InvalidAuthTokenRepository); mockInstance(InvalidAuthTokenRepository);
mockInstance(License).isWithinUsersLimit.mockReturnValue(true); mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
@ -412,27 +417,63 @@ describe('MeController', () => {
describe('API Key methods', () => { describe('API Key methods', () => {
let req: AuthenticatedRequest; let req: AuthenticatedRequest;
beforeAll(() => { beforeAll(() => {
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) }); req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
}); });
describe('createAPIKey', () => { describe('createAPIKey', () => {
it('should create and save an API key', async () => { it('should create and save an API key', async () => {
const { apiKey } = await controller.createAPIKey(req); const apiKeyData = {
expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey }); 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', () => { describe('getAPIKeys', () => {
it('should return the users api key redacted', async () => { it('should return the users api keys redacted', async () => {
const { apiKey } = await controller.getAPIKey(req); const apiKeyData = {
expect(apiKey).not.toEqual(req.user.apiKey); 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', () => { describe('deleteAPIKey', () => {
it('should delete the API key', async () => { it('should delete the API key', async () => {
const user = mock<User>({
id: '123',
password: 'password',
authIdentities: [],
role: 'global:member',
mfaEnabled: false,
});
const req = mock<MeRequest.DeleteAPIKey>({ user, params: { id: user.id } });
await controller.deleteAPIKey(req); 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,
});
}); });
}); });
}); });

View file

@ -4,7 +4,6 @@ import {
UserUpdateRequestDto, UserUpdateRequestDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { randomBytes } from 'crypto';
import { type RequestHandler, Response } from 'express'; import { type RequestHandler, Response } from 'express';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
@ -22,13 +21,12 @@ import { MfaService } from '@/mfa/mfa.service';
import { isApiEnabled } from '@/public-api'; import { isApiEnabled } from '@/public-api';
import { AuthenticatedRequest, MeRequest } from '@/requests'; import { AuthenticatedRequest, MeRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility'; import { PasswordUtility } from '@/services/password.utility';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers';
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
export const API_KEY_PREFIX = 'n8n_api_';
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
if (isApiEnabled()) { if (isApiEnabled()) {
next(); next();
@ -48,6 +46,7 @@ export class MeController {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly mfaService: MfaService, 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) { async createAPIKey(req: AuthenticatedRequest) {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user);
await this.userService.update(req.user.id, { apiKey });
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); 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] }) @Get('/api-keys', { middlewares: [isApiEnabledMiddleware] })
async getAPIKey(req: AuthenticatedRequest) { async getAPIKeys(req: AuthenticatedRequest) {
const apiKey = this.redactApiKey(req.user.apiKey); const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user);
return { apiKey }; return apiKeys;
} }
/** /**
* Deletes an API Key * Delete an API Key
*/ */
@Delete('/api-key', { middlewares: [isApiEnabledMiddleware] }) @Delete('/api-keys/:id', { middlewares: [isApiEnabledMiddleware] })
async deleteAPIKey(req: AuthenticatedRequest) { async deleteAPIKey(req: MeRequest.DeleteAPIKey) {
await this.userService.update(req.user.id, { apiKey: null }); await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id);
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false }); this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
@ -273,14 +270,4 @@ export class MeController {
return user.settings; 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)
);
}
} }

View file

@ -8,7 +8,6 @@ describe('User Entity', () => {
firstName: 'Don', firstName: 'Don',
lastName: 'Joe', lastName: 'Joe',
password: '123456789', password: '123456789',
apiKey: '123',
}); });
expect(JSON.stringify(user)).toEqual( expect(JSON.stringify(user)).toEqual(
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}', '{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',

View file

@ -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;
}

View file

@ -1,5 +1,6 @@
import { AnnotationTagEntity } from './annotation-tag-entity.ee'; import { AnnotationTagEntity } from './annotation-tag-entity.ee';
import { AnnotationTagMapping } from './annotation-tag-mapping.ee'; import { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ApiKey } from './api-key';
import { AuthIdentity } from './auth-identity'; import { AuthIdentity } from './auth-identity';
import { AuthProviderSyncHistory } from './auth-provider-sync-history'; import { AuthProviderSyncHistory } from './auth-provider-sync-history';
import { AuthUser } from './auth-user'; import { AuthUser } from './auth-user';
@ -54,4 +55,5 @@ export const entities = {
WorkflowHistory, WorkflowHistory,
Project, Project,
ProjectRelation, ProjectRelation,
ApiKey,
}; };

View file

@ -23,6 +23,7 @@ import { NoUrl } from '@/validators/no-url.validator';
import { NoXss } from '@/validators/no-xss.validator'; import { NoXss } from '@/validators/no-xss.validator';
import { WithTimestamps, jsonColumnType } from './abstract-entity'; import { WithTimestamps, jsonColumnType } from './abstract-entity';
import type { ApiKey } from './api-key';
import type { AuthIdentity } from './auth-identity'; import type { AuthIdentity } from './auth-identity';
import type { ProjectRelation } from './project-relation'; import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials'; import type { SharedCredentials } from './shared-credentials';
@ -89,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
@OneToMany('AuthIdentity', 'user') @OneToMany('AuthIdentity', 'user')
authIdentities: AuthIdentity[]; authIdentities: AuthIdentity[];
@OneToMany('ApiKey', 'user')
apiKeys: ApiKey[];
@OneToMany('SharedWorkflow', 'user') @OneToMany('SharedWorkflow', 'user')
sharedWorkflows: SharedWorkflow[]; sharedWorkflows: SharedWorkflow[];
@ -107,10 +111,6 @@ export class User extends WithTimestamps implements IUser {
this.email = this.email?.toLowerCase() ?? null; this.email = this.email?.toLowerCase() ?? null;
} }
@Column({ type: String, nullable: true })
@Index({ unique: true })
apiKey: string | null;
@Column({ type: Boolean, default: false }) @Column({ type: Boolean, default: false })
mfaEnabled: boolean; mfaEnabled: boolean;
@ -151,7 +151,7 @@ export class User extends WithTimestamps implements IUser {
} }
toJSON() { toJSON() {
const { password, apiKey, ...rest } = this; const { password, ...rest } = this;
return rest; return rest;
} }

View file

@ -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<Partial<ApiKey>>;
// 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};`);
}
}

View file

@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];

View file

@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -128,4 +129,5 @@ export const postgresMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];

View file

@ -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<Partial<ApiKey>>;
// 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;');
}
}

View file

@ -37,6 +37,7 @@ import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns';
import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete'; import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -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<ApiKey> {
constructor(dataSource: DataSource) {
super(ApiKey, dataSource.manager);
}
}

View file

@ -10,10 +10,10 @@ import { Container } from 'typedi';
import validator from 'validator'; import validator from 'validator';
import YAML from 'yamljs'; import YAML from 'yamljs';
import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { License } from '@/license'; import { License } from '@/license';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
async function createApiRouter( async function createApiRouter(
@ -90,10 +90,9 @@ async function createApiRouter(
_scopes: unknown, _scopes: unknown,
schema: OpenAPIV3.ApiKeySecurityScheme, schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => { ): Promise<boolean> => {
const apiKey = req.headers[schema.name.toLowerCase()] as string; const providedApiKey = req.headers[schema.name.toLowerCase()] as string;
const user = await Container.get(UserRepository).findOne({
where: { apiKey }, const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey);
});
if (!user) return false; if (!user) return false;

View file

@ -186,6 +186,7 @@ export declare namespace CredentialRequest {
export declare namespace MeRequest { export declare namespace MeRequest {
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>;
} }
export interface UserSetupPayload { export interface UserSetupPayload {

View file

@ -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')}`;
}

View file

@ -58,7 +58,7 @@ export class UserService {
withScopes?: boolean; withScopes?: boolean;
}, },
) { ) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user; const { password, updatedAt, authIdentities, ...rest } = user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');

View file

@ -10,7 +10,6 @@ export function assertReturnedUserProps(user: User) {
expect(user.personalizationAnswers).toBeNull(); expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeUndefined(); expect(user.password).toBeUndefined();
expect(user.isPending).toBe(false); expect(user.isPending).toBe(false);
expect(user.apiKey).not.toBeDefined();
expect(user.globalScopes).toBeDefined(); expect(user.globalScopes).toBeDefined();
expect(user.globalScopes).not.toHaveLength(0); expect(user.globalScopes).not.toHaveLength(0);
} }

View file

@ -1,22 +1,29 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { IsNull } from '@n8n/typeorm';
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import validator from 'validator'; import validator from 'validator';
import type { ApiKey } from '@/databases/entities/api-key';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { addApiKey, createOwner, createUser, createUserShell } from './shared/db/users'; import { createOwnerWithApiKey, createUser, createUserShell } from './shared/db/users';
import { randomApiKey, randomEmail, randomName, randomValidPassword } from './shared/random'; import { randomEmail, randomName, randomValidPassword } from './shared/random';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
import type { SuperAgentTest } from './shared/types'; import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
let publicApiKeyService: PublicApiKeyService;
beforeAll(() => {
publicApiKeyService = Container.get(PublicApiKeyService);
});
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['User']); await testDb.truncate(['User']);
@ -28,22 +35,22 @@ describe('When public API is disabled', () => {
let authAgent: SuperAgentTest; let authAgent: SuperAgentTest;
beforeEach(async () => { beforeEach(async () => {
owner = await createOwner(); owner = await createOwnerWithApiKey();
await addApiKey(owner);
authAgent = testServer.authAgentFor(owner); authAgent = testServer.authAgentFor(owner);
mockInstance(GlobalConfig, { publicApi: { disabled: true } }); mockInstance(GlobalConfig, { publicApi: { disabled: true } });
}); });
test('POST /me/api-key should 404', async () => { test('POST /me/api-keys should 404', async () => {
await authAgent.post('/me/api-key').expect(404); await authAgent.post('/me/api-keys').expect(404);
}); });
test('GET /me/api-key should 404', async () => { test('GET /me/api-keys should 404', async () => {
await authAgent.get('/me/api-key').expect(404); await authAgent.get('/me/api-keys').expect(404);
}); });
test('DELETE /me/api-key should 404', async () => { test('DELETE /me/api-key/:id should 404', async () => {
await authAgent.delete('/me/api-key').expect(404); await authAgent.delete(`/me/api-keys/${1}`).expect(404);
}); });
}); });
@ -53,7 +60,6 @@ describe('Owner shell', () => {
beforeEach(async () => { beforeEach(async () => {
ownerShell = await createUserShell('global:owner'); ownerShell = await createUserShell('global:owner');
await addApiKey(ownerShell);
authOwnerShellAgent = testServer.authAgentFor(ownerShell); authOwnerShellAgent = testServer.authAgentFor(ownerShell);
}); });
@ -63,17 +69,8 @@ describe('Owner shell', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const { const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
id, response.body.data;
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true); expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase()); expect(email).toBe(validPayload.email.toLowerCase());
@ -83,7 +80,6 @@ describe('Owner shell', () => {
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(role).toBe('global:owner'); expect(role).toBe('global:owner');
expect(apiKey).toBeUndefined();
const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id }); 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 () => { test('POST /me/api-keys should create an api key', async () => {
const response = await authOwnerShellAgent.post('/me/api-key'); const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
expect(response.statusCode).toBe(200); const newApiKey = newApiKeyResponse.body.data as ApiKey;
expect(response.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull();
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ expect(newApiKeyResponse.statusCode).toBe(200);
where: { email: IsNull() }, expect(newApiKey).toBeDefined();
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: ownerShell.id,
}); });
expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey); 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),
});
}); });
test('GET /me/api-key should fetch the api key redacted', async () => { test('GET /me/api-keys should fetch the api key redacted', async () => {
const response = await authOwnerShellAgent.get('/me/api-key'); const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
expect(response.statusCode).toBe(200); const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys');
expect(response.body.data.apiKey).not.toEqual(ownerShell.apiKey);
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-key should delete the api key', async () => { test('DELETE /me/api-keys/:id should delete the api key', async () => {
const response = await authOwnerShellAgent.delete('/me/api-key'); const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
expect(response.statusCode).toBe(200); const deleteApiKeyResponse = await authOwnerShellAgent.delete(
`/me/api-keys/${newApiKeyResponse.body.data.id}`,
);
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys');
where: { email: IsNull() },
});
expect(storedShellOwner.apiKey).toBeNull(); expect(deleteApiKeyResponse.body.data.success).toBe(true);
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
}); });
}); });
@ -204,10 +219,8 @@ describe('Member', () => {
member = await createUser({ member = await createUser({
password: memberPassword, password: memberPassword,
role: 'global:member', role: 'global:member',
apiKey: randomApiKey(),
}); });
authMemberAgent = testServer.authAgentFor(member); authMemberAgent = testServer.authAgentFor(member);
await utils.setInstanceOwnerSetUp(true); await utils.setInstanceOwnerSetUp(true);
}); });
@ -215,17 +228,8 @@ describe('Member', () => {
for (const validPayload of VALID_PATCH_ME_PAYLOADS) { for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
const { const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
id, response.body.data;
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true); expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase()); expect(email).toBe(validPayload.email.toLowerCase());
@ -235,7 +239,6 @@ describe('Member', () => {
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(role).toBe('global:member'); expect(role).toBe('global:member');
expect(apiKey).toBeUndefined();
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
@ -275,6 +278,7 @@ describe('Member', () => {
}; };
const response = await authMemberAgent.patch('/me/password').send(validPayload); const response = await authMemberAgent.patch('/me/password').send(validPayload);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
@ -315,33 +319,59 @@ describe('Member', () => {
} }
}); });
test('POST /me/api-key should create an api key', async () => { test('POST /me/api-keys should create an api key', async () => {
const response = await testServer.authAgentFor(member).post('/me/api-key'); const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
expect(response.statusCode).toBe(200); expect(newApiKeyResponse.statusCode).toBe(200);
expect(response.body.data.apiKey).toBeDefined(); expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull(); 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);
}); });
test('GET /me/api-key should fetch the api key redacted', async () => { expect(newStoredApiKey).toEqual({
const response = await testServer.authAgentFor(member).get('/me/api-key'); id: expect.any(String),
label: 'My API Key',
expect(response.statusCode).toBe(200); userId: member.id,
expect(response.body.data.apiKey).not.toEqual(member.apiKey); apiKey: newApiKeyResponse.body.data.apiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
});
}); });
test('DELETE /me/api-key should delete the api key', async () => { test('GET /me/api-keys should fetch the api key redacted', async () => {
const response = await testServer.authAgentFor(member).delete('/me/api-key'); const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
expect(response.statusCode).toBe(200); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys');
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id }); expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
expect(storedMember.apiKey).toBeNull(); 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-keys/:id should delete the api key', async () => {
const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
const deleteApiKeyResponse = await testServer
.authAgentFor(member)
.delete(`/me/api-keys/${newApiKeyResponse.body.data.id}`);
const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys');
expect(deleteApiKeyResponse.body.data.success).toBe(true);
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
}); });
}); });

View file

@ -7,8 +7,8 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
import { createTeamProject } from '@test-integration/db/projects'; import { createTeamProject } from '@test-integration/db/projects';
import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials';
import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { randomApiKey, randomName } from '../shared/random'; import { randomName } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
@ -24,8 +24,8 @@ let saveCredential: SaveCredentialFunction;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await addApiKey(await createUserShell('global:owner')); owner = await createOwnerWithApiKey();
member = await createUser({ role: 'global:member', apiKey: randomApiKey() }); member = await createMemberWithApiKey();
authOwnerAgent = testServer.publicApiAgentFor(owner); authOwnerAgent = testServer.publicApiAgentFor(owner);
authMemberAgent = testServer.publicApiAgentFor(member); authMemberAgent = testServer.publicApiAgentFor(member);
@ -156,10 +156,7 @@ describe('DELETE /credentials/:id', () => {
}); });
test('should delete owned cred for member but leave others untouched', async () => { test('should delete owned cred for member but leave others untouched', async () => {
const anotherMember = await createUser({ const anotherMember = await createMemberWithApiKey();
role: 'global:member',
apiKey: randomApiKey(),
});
const savedCredential = await saveCredential(dbCredential(), { user: member }); const savedCredential = await saveCredential(dbCredential(), { user: member });
const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member }); const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member });

View file

@ -12,13 +12,12 @@ import {
createSuccessfulExecution, createSuccessfulExecution,
createWaitingExecution, createWaitingExecution,
} from '../shared/db/executions'; } from '../shared/db/executions';
import { createUser } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { import {
createManyWorkflows, createManyWorkflows,
createWorkflow, createWorkflow,
shareWorkflowWithUsers, shareWorkflowWithUsers,
} from '../shared/db/workflows'; } from '../shared/db/workflows';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -36,9 +35,9 @@ mockInstance(Telemetry);
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() }); owner = await createOwnerWithApiKey();
user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); user1 = await createMemberWithApiKey();
user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); user2 = await createMemberWithApiKey();
// TODO: mock BinaryDataService instead // TODO: mock BinaryDataService instead
await utils.initBinaryDataService(); await utils.initBinaryDataService();

View file

@ -2,7 +2,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; 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 { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@ -26,7 +26,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projects = await Promise.all([ const projects = await Promise.all([
createTeamProject(), createTeamProject(),
createTeamProject(), createTeamProject(),
@ -53,15 +53,10 @@ describe('Projects in Public API', () => {
}); });
it('if not authenticated, should reject', async () => { it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwner({ withApiKey: false });
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).get('/projects'); const response = await testServer.publicApiAgentWithoutApiKey().get('/projects');
/** /**
* Assert * Assert
@ -74,7 +69,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
/** /**
* Act * Act
@ -97,12 +92,12 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).get('/projects'); const response = await testServer.publicApiAgentFor(member).get('/projects');
/** /**
* Assert * Assert
@ -119,7 +114,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -150,14 +145,13 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(owner) .publicApiAgentWithoutApiKey()
.post('/projects') .post('/projects')
.send(projectPayload); .send(projectPayload);
@ -172,7 +166,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -199,7 +193,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -225,7 +219,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -244,13 +238,14 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); const response = await testServer
.publicApiAgentWithoutApiKey()
.delete(`/projects/${project.id}`);
/** /**
* Assert * Assert
@ -263,7 +258,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -287,13 +282,13 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const owner = await createMemberWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
/** /**
* Assert * Assert
@ -310,7 +305,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject('old-name'); const project = await createTeamProject('old-name');
/** /**
@ -332,14 +327,13 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(owner) .publicApiAgentWithoutApiKey()
.put(`/projects/${project.id}`) .put(`/projects/${project.id}`)
.send({ name: 'new-name' }); .send({ name: 'new-name' });
@ -354,7 +348,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -381,7 +375,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**

View file

@ -4,8 +4,7 @@ import type { User } from '@/databases/entities/user';
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { createTag } from '../shared/db/tags'; import { createTag } from '../shared/db/tags';
import { createUser } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -18,15 +17,8 @@ let authMemberAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ owner = await createOwnerWithApiKey();
role: 'global:owner', member = await createMemberWithApiKey();
apiKey: randomApiKey(),
});
member = await createUser({
role: 'global:member',
apiKey: randomApiKey(),
});
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -6,8 +6,13 @@ import { License } from '@/license';
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { createOwner, createUser, createUserShell } from '../shared/db/users'; import {
import { randomApiKey } from '../shared/random'; createMember,
createMemberWithApiKey,
createOwnerWithApiKey,
createUser,
createUserShell,
} from '../shared/db/users';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -25,32 +30,23 @@ beforeEach(async () => {
describe('With license unlimited quota:users', () => { describe('With license unlimited quota:users', () => {
describe('GET /users', () => { describe('GET /users', () => {
test('should fail due to missing API Key', async () => { test('should fail due to missing API Key', async () => {
const owner = await createUser({ role: 'global:owner' }); const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users').expect(401); await authOwnerAgent.get('/users').expect(401);
}); });
test('should fail due to invalid API Key', async () => { test('should fail due to invalid API Key', async () => {
const owner = await createUser({ const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
role: 'global:owner',
apiKey: randomApiKey(),
});
owner.apiKey = 'invalid-key';
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users').expect(401); await authOwnerAgent.get('/users').expect(401);
}); });
test('should fail due to member trying to access owner only endpoint', async () => { 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); const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get('/users').expect(403); await authMemberAgent.get('/users').expect(403);
}); });
test('should return all users', async () => { test('should return all users', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
@ -92,10 +88,10 @@ describe('With license unlimited quota:users', () => {
* Arrange * Arrange
*/ */
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
createOwner({ withApiKey: true }), createOwnerWithApiKey(),
createUser({ role: 'global:member' }), createMember(),
createUser({ role: 'global:member' }), createMember(),
createUser({ role: 'global:member' }), createMember(),
]); ]);
const [firstProject, secondProject] = await Promise.all([ const [firstProject, secondProject] = await Promise.all([
@ -130,40 +126,30 @@ describe('With license unlimited quota:users', () => {
describe('GET /users/:id', () => { describe('GET /users/:id', () => {
test('should fail due to missing API Key', async () => { test('should fail due to missing API Key', async () => {
const owner = await createUser({ role: 'global:owner' }); const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
await authOwnerAgent.get(`/users/${owner.id}`).expect(401); await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
}); });
test('should fail due to invalid API Key', async () => { test('should fail due to invalid API Key', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner', const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
apiKey: randomApiKey(),
});
owner.apiKey = 'invalid-key';
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get(`/users/${owner.id}`).expect(401); await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
}); });
test('should fail due to member trying to access owner only endpoint', async () => { 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); const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get(`/users/${member.id}`).expect(403); await authMemberAgent.get(`/users/${member.id}`).expect(403);
}); });
test('should return 404 for non-existing id ', async () => { test('should return 404 for non-existing id ', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get(`/users/${uuid()}`).expect(404); await authOwnerAgent.get(`/users/${uuid()}`).expect(404);
}); });
test('should return a pending user', async () => { test('should return a pending user', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const { id: memberId } = await createUserShell('global:member'); const { id: memberId } = await createUserShell('global:member');
@ -199,20 +185,13 @@ describe('With license unlimited quota:users', () => {
describe('GET /users/:email', () => { describe('GET /users/:email', () => {
test('with non-existing email should return 404', async () => { test('with non-existing email should return 404', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404); await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404);
}); });
test('should return a user', async () => { test('should return a user', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200); const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200);
@ -249,10 +228,7 @@ describe('With license without quota:users', () => {
beforeEach(async () => { beforeEach(async () => {
mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) }); mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
authOwnerAgent = testServer.publicApiAgentFor(owner); authOwnerAgent = testServer.publicApiAgentFor(owner);
}); });

View file

@ -1,7 +1,12 @@
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking'; 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 { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@ -23,13 +28,12 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const payload = { email: 'test@test.com', role: 'global:admin' }; const payload = { email: 'test@test.com', role: 'global:admin' };
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload);
/** /**
* Assert * Assert
@ -42,7 +46,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const payload = [{ email: 'test@test.com', role: 'global:admin' }]; const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/** /**
@ -62,7 +66,8 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); 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' }]; const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/** /**
@ -99,13 +104,12 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const member = await createMember(); const member = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`);
/** /**
* Assert * Assert
@ -118,14 +122,14 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const firstMember = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const secondMember = await createMember(); const secondMember = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(firstMember) .publicApiAgentFor(member)
.delete(`/users/${secondMember.id}`); .delete(`/users/${secondMember.id}`);
/** /**
@ -140,7 +144,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
/** /**
@ -161,13 +165,14 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const member = await createMember(); const member = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); const response = await testServer
.publicApiAgentWithApiKey('')
.patch(`/users/${member.id}/role`);
/** /**
* Assert * Assert
@ -179,7 +184,7 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };
@ -206,7 +211,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const firstMember = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const secondMember = await createMember(); const secondMember = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };
@ -214,7 +219,7 @@ describe('Users in Public API', () => {
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(firstMember) .publicApiAgentFor(member)
.patch(`/users/${secondMember.id}/role`) .patch(`/users/${secondMember.id}/role`)
.send(payload); .send(payload);
@ -230,7 +235,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'invalid' }; const payload = { newRoleName: 'invalid' };
@ -253,7 +258,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };

View file

@ -1,5 +1,5 @@
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; 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 { createVariable, getVariableOrFail } from '@test-integration/db/variables';
import { setupTestServer } from '@test-integration/utils'; import { setupTestServer } from '@test-integration/utils';
@ -22,7 +22,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variables = await Promise.all([createVariable(), createVariable(), createVariable()]); const variables = await Promise.all([createVariable(), createVariable(), createVariable()]);
/** /**
@ -48,7 +48,8 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true });
const owner = await createOwnerWithApiKey();
/** /**
* Act * Act
@ -72,7 +73,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variablePayload = { key: 'key', value: 'value' }; const variablePayload = { key: 'key', value: 'value' };
/** /**
@ -96,7 +97,7 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variablePayload = { key: 'key', value: 'value' }; const variablePayload = { key: 'key', value: 'value' };
/** /**
@ -124,7 +125,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variable = await createVariable(); const variable = await createVariable();
/** /**
@ -145,7 +146,7 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variable = await createVariable(); const variable = await createVariable();
/** /**

View file

@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { createTag } from '../shared/db/tags'; 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 { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -40,18 +39,13 @@ const license = testServer.license;
mockInstance(ExecutionService); mockInstance(ExecutionService);
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id, owner.id,
); );
member = await createUser({ member = await createMemberWithApiKey();
role: 'global:member',
apiKey: randomApiKey(),
});
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
member.id, member.id,
); );

View file

@ -1,8 +1,10 @@
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { randomString } from 'n8n-workflow';
import Container from 'typedi'; import Container from 'typedi';
import { AuthIdentity } from '@/databases/entities/auth-identity'; import { AuthIdentity } from '@/databases/entities/auth-identity';
import { type GlobalRole, type User } from '@/databases/entities/user'; 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 { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -79,19 +81,38 @@ export async function createUserWithMfaEnabled(
}; };
} }
export async function createOwner({ withApiKey } = { withApiKey: false }) { const createApiKeyEntity = (user: User) => {
if (withApiKey) { const apiKey = randomApiKey();
return await addApiKey(await createUser({ role: 'global:owner' })); 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' }); return await createUser({ role: 'global:owner' });
} }
export async function createMember({ withApiKey } = { withApiKey: false }) { export async function createMember() {
if (withApiKey) {
return await addApiKey(await createUser({ role: 'global:member' }));
}
return await createUser({ role: 'global:member' }); return await createUser({ role: 'global:member' });
} }
@ -128,11 +149,6 @@ export async function createManyUsers(
return result.map((result) => result.user); return result.map((result) => result.user);
} }
export async function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey();
return await Container.get(UserRepository).save(user);
}
export const getAllUsers = async () => export const getAllUsers = async () =>
await Container.get(UserRepository).find({ await Container.get(UserRepository).find({
relations: ['authIdentities'], relations: ['authIdentities'],

View file

@ -80,6 +80,7 @@ const repositories = [
'WorkflowHistory', 'WorkflowHistory',
'WorkflowStatistics', 'WorkflowStatistics',
'WorkflowTagMapping', 'WorkflowTagMapping',
'ApiKey',
] as const; ] as const;
/** /**

View file

@ -55,6 +55,8 @@ export interface TestServer {
httpServer: Server; httpServer: Server;
authAgentFor: (user: User) => TestAgent; authAgentFor: (user: User) => TestAgent;
publicApiAgentFor: (user: User) => TestAgent; publicApiAgentFor: (user: User) => TestAgent;
publicApiAgentWithApiKey: (apiKey: string) => TestAgent;
publicApiAgentWithoutApiKey: () => TestAgent;
authlessAgent: TestAgent; authlessAgent: TestAgent;
restlessAgent: TestAgent; restlessAgent: TestAgent;
license: LicenseMocker; license: LicenseMocker;

View file

@ -62,17 +62,30 @@ function createAgent(
return agent; return agent;
} }
function publicApiAgent( const userDoesNotHaveApiKey = (user: User) => {
return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0;
};
const publicApiAgent = (
app: express.Application, 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); const agent = request.agent(app);
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`)); void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
if (user.apiKey) { if (!user && !apiKey) return agent;
void agent.set({ 'X-N8N-API-KEY': user.apiKey }); void agent.set({ 'X-N8N-API-KEY': agentApiKey });
}
return agent; return agent;
} };
export const setupTestServer = ({ export const setupTestServer = ({
endpointGroups, endpointGroups,
@ -100,6 +113,8 @@ export const setupTestServer = ({
authlessAgent: createAgent(app), authlessAgent: createAgent(app),
restlessAgent: createAgent(app, { auth: false, noRest: true }), restlessAgent: createAgent(app, { auth: false, noRest: true }),
publicApiAgentFor: (user) => publicApiAgent(app, { user }), publicApiAgentFor: (user) => publicApiAgent(app, { user }),
publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }),
publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}),
license: new LicenseMocker(), license: new LicenseMocker(),
}; };

View file

@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterpr
export interface IN8nPromptResponse { export interface IN8nPromptResponse {
updated: boolean; updated: boolean;
} }
export type ApiKey = {
id: string;
label: string;
apiKey: string;
createdAt: string;
updatedAt: string;
};

View file

@ -1,14 +1,17 @@
import type { IRestApiContext } from '@/Interface'; import type { ApiKey, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
export async function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/me/api-key'); return await makeRestApiRequest(context, 'GET', '/me/api-keys');
} }
export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/me/api-key'); return await makeRestApiRequest(context, 'POST', '/me/api-keys');
} }
export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> { export async function deleteApiKey(
return await makeRestApiRequest(context, 'DELETE', '/me/api-key'); context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`);
} }

View file

@ -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.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.api": "n8n API",
"settings.api.view.info.webhook": "webhook node", "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.tryapi": "Try it out using the",
"settings.api.view.more-details": "You can find more details in", "settings.api.view.more-details": "You can find more details in",
"settings.api.view.external-docs": "the API documentation", "settings.api.view.external-docs": "the API documentation",

View file

@ -308,21 +308,19 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
templatesEndpointHealthy.value = true; templatesEndpointHealthy.value = true;
}; };
const getApiKey = async () => { const getApiKeys = async () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const { apiKey } = await publicApiApi.getApiKey(rootStore.restApiContext); return await publicApiApi.getApiKeys(rootStore.restApiContext);
return apiKey;
}; };
const createApiKey = async () => { const createApiKey = async () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const { apiKey } = await publicApiApi.createApiKey(rootStore.restApiContext); return await publicApiApi.createApiKey(rootStore.restApiContext);
return apiKey;
}; };
const deleteApiKey = async () => { const deleteApiKey = async (id: string) => {
const rootStore = useRootStore(); const rootStore = useRootStore();
await publicApiApi.deleteApiKey(rootStore.restApiContext); await publicApiApi.deleteApiKey(rootStore.restApiContext, id);
}; };
const getLdapConfig = async () => { const getLdapConfig = async () => {
@ -423,7 +421,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
runLdapSync, runLdapSync,
getTimezones, getTimezones,
createApiKey, createApiKey,
getApiKey, getApiKeys,
deleteApiKey, deleteApiKey,
testTemplatesEndpoint, testTemplatesEndpoint,
submitContactInfo, submitContactInfo,

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import type { IUser } from '@/Interface'; import type { ApiKey, IUser } from '@/Interface';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
@ -29,7 +29,7 @@ export default defineComponent({
return { return {
loading: false, loading: false,
mounted: false, mounted: false,
apiKey: '', apiKeys: [] as ApiKey[],
swaggerUIEnabled: false, swaggerUIEnabled: false,
apiDocsURL: '', apiDocsURL: '',
}; };
@ -37,7 +37,7 @@ export default defineComponent({
mounted() { mounted() {
if (!this.isPublicApiEnabled) return; if (!this.isPublicApiEnabled) return;
void this.getApiKey(); void this.getApiKeys();
const baseUrl = this.rootStore.baseUrl; const baseUrl = this.rootStore.baseUrl;
const apiPath = this.settingsStore.publicApiPath; const apiPath = this.settingsStore.publicApiPath;
const latestVersion = this.settingsStore.publicApiLatestVersion; const latestVersion = this.settingsStore.publicApiLatestVersion;
@ -61,7 +61,8 @@ export default defineComponent({
return this.settingsStore.isPublicApiEnabled; return this.settingsStore.isPublicApiEnabled;
}, },
isRedactedApiKey(): boolean { isRedactedApiKey(): boolean {
return this.apiKey.includes('*'); if (!this.apiKeys) return false;
return this.apiKeys[0].apiKey.includes('*');
}, },
}, },
methods: { methods: {
@ -81,9 +82,9 @@ export default defineComponent({
await this.deleteApiKey(); await this.deleteApiKey();
} }
}, },
async getApiKey() { async getApiKeys() {
try { try {
this.apiKey = (await this.settingsStore.getApiKey()) || ''; this.apiKeys = await this.settingsStore.getApiKeys();
} catch (error) { } catch (error) {
this.showError(error, this.$locale.baseText('settings.api.view.error')); this.showError(error, this.$locale.baseText('settings.api.view.error'));
} finally { } finally {
@ -94,7 +95,8 @@ export default defineComponent({
this.loading = true; this.loading = true;
try { try {
this.apiKey = (await this.settingsStore.createApiKey()) || ''; const newApiKey = await this.settingsStore.createApiKey();
this.apiKeys.push(newApiKey);
} catch (error) { } catch (error) {
this.showError(error, this.$locale.baseText('settings.api.create.error')); this.showError(error, this.$locale.baseText('settings.api.create.error'));
} finally { } finally {
@ -104,12 +106,12 @@ export default defineComponent({
}, },
async deleteApiKey() { async deleteApiKey() {
try { try {
await this.settingsStore.deleteApiKey(); await this.settingsStore.deleteApiKey(this.apiKeys[0].id);
this.showMessage({ this.showMessage({
title: this.$locale.baseText('settings.api.delete.toast'), title: this.$locale.baseText('settings.api.delete.toast'),
type: 'success', type: 'success',
}); });
this.apiKey = ''; this.apiKeys = [];
} catch (error) { } catch (error) {
this.showError(error, this.$locale.baseText('settings.api.delete.error')); this.showError(error, this.$locale.baseText('settings.api.delete.error'));
} finally { } finally {
@ -134,7 +136,7 @@ export default defineComponent({
</n8n-heading> </n8n-heading>
</div> </div>
<div v-if="apiKey"> <div v-if="apiKeys.length">
<p class="mb-s"> <p class="mb-s">
<n8n-info-tip :bold="false"> <n8n-info-tip :bold="false">
<i18n-t keypath="settings.api.view.info" tag="span"> <i18n-t keypath="settings.api.view.info" tag="span">
@ -161,10 +163,11 @@ export default defineComponent({
{{ $locale.baseText('generic.delete') }} {{ $locale.baseText('generic.delete') }}
</n8n-link> </n8n-link>
</span> </span>
<div> <div>
<CopyInput <CopyInput
:label="$locale.baseText('settings.api.view.myKey')" :label="apiKeys[0].label"
:value="apiKey" :value="apiKeys[0].apiKey"
:copy-button-text="$locale.baseText('generic.clickToCopy')" :copy-button-text="$locale.baseText('generic.clickToCopy')"
:toast-title="$locale.baseText('settings.api.view.copy.toast')" :toast-title="$locale.baseText('settings.api.view.copy.toast')"
:redact-value="true" :redact-value="true"