mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
refactor: Move API keys into their own table (no-changelog) (#10629)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
7e79a46750
commit
a13a4f7442
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"}',
|
||||
|
|
25
packages/cli/src/databases/entities/api-key.ts
Normal file
25
packages/cli/src/databases/entities/api-key.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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};`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;');
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
80
packages/cli/src/services/public-api-key.service.ts
Normal file
80
packages/cli/src/services/public-api-key.service.ts
Normal 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')}`;
|
||||
}
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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' };
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -80,6 +80,7 @@ const repositories = [
|
|||
'WorkflowHistory',
|
||||
'WorkflowStatistics',
|
||||
'WorkflowTagMapping',
|
||||
'ApiKey',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue