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 { 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<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) });
req = mock<AuthenticatedRequest>({ user: mock<User>({ 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<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);
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,
} 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)
);
}
}

View file

@ -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"}',

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 { 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,
};

View file

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

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 { 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,
];

View file

@ -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,
];

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

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 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<boolean> => {
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;

View file

@ -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 {

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;
},
) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
const { password, updatedAt, authIdentities, ...rest } = user;
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.password).toBeUndefined();
expect(user.isPending).toBe(false);
expect(user.apiKey).not.toBeDefined();
expect(user.globalScopes).toBeDefined();
expect(user.globalScopes).not.toHaveLength(0);
}

View file

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

View file

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

View file

@ -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();

View file

@ -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();
/**

View file

@ -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 () => {

View file

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

View file

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

View file

@ -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();
/**

View file

@ -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,
);

View file

@ -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> {
user.apiKey = randomApiKey();
return await Container.get(UserRepository).save(user);
}
export const getAllUsers = async () =>
await Container.get(UserRepository).find({
relations: ['authIdentities'],

View file

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

View file

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

View file

@ -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(),
};

View file

@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterpr
export interface IN8nPromptResponse {
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';
export async function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
return await makeRestApiRequest(context, 'GET', '/me/api-key');
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
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<ApiKey> {
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}`);
}

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.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",

View file

@ -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,

View file

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