mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 02:59:27 +02:00
feat(core): Revamp infisical implementation (#30843)
This commit is contained in:
parent
f1d87fddb2
commit
a82384f90d
|
|
@ -179,7 +179,6 @@
|
|||
"handlebars": "4.7.9",
|
||||
"helmet": "8.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"infisical-node": "1.3.0",
|
||||
"ioredis": "5.3.2",
|
||||
"isbot": "3.6.13",
|
||||
"isolated-vm": "catalog:",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,436 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { InfisicalProvider } from '../infisical';
|
||||
|
||||
const SITE_URL = 'https://app.infisical.com';
|
||||
const PROJECT_ID = 'project-123';
|
||||
const ENVIRONMENT = 'dev';
|
||||
const SECRET_PATH = '/';
|
||||
|
||||
const universalAuthSettings = {
|
||||
connected: true,
|
||||
connectedAt: new Date(),
|
||||
settings: {
|
||||
siteURL: SITE_URL,
|
||||
projectId: PROJECT_ID,
|
||||
environment: ENVIRONMENT,
|
||||
secretPath: SECRET_PATH,
|
||||
authMethod: 'universalAuth',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
},
|
||||
};
|
||||
|
||||
function workspaceResponse() {
|
||||
return {
|
||||
workspace: {
|
||||
id: PROJECT_ID,
|
||||
name: 'Test Project',
|
||||
environments: [{ name: 'Development', slug: 'dev' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function universalAuthLoginResponse(accessToken = 'new-access-token', expiresIn = 7200) {
|
||||
return {
|
||||
accessToken,
|
||||
expiresIn,
|
||||
accessTokenMaxTTL: 43244,
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
type ImportFixture = {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secrets: Array<{ secretKey: string; secretValue: string }>;
|
||||
};
|
||||
|
||||
function listSecretsResponse(
|
||||
secrets: Array<{ secretKey: string; secretValue: string }>,
|
||||
imports: ImportFixture[] = [],
|
||||
) {
|
||||
return { secrets, imports };
|
||||
}
|
||||
|
||||
describe('InfisicalProvider', () => {
|
||||
const logger = mockInstance(Logger);
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.cleanAll();
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
describe('test', () => {
|
||||
it('returns success when the workspace endpoint returns 200', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
const [success] = await provider.test();
|
||||
expect(success).toBe(true);
|
||||
scope.done();
|
||||
});
|
||||
|
||||
it('returns "Invalid credentials" on 401', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(401, { message: 'Unauthorized' });
|
||||
|
||||
const [success, message] = await provider.test();
|
||||
expect(success).toBe(false);
|
||||
expect(message).toBe('Invalid credentials');
|
||||
scope.done();
|
||||
});
|
||||
|
||||
it('returns "Project not found" on 404', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(404, { message: 'Not found' });
|
||||
|
||||
const [success, message] = await provider.test();
|
||||
expect(success).toBe(false);
|
||||
expect(message).toBe('Project not found. Check the Project ID and Site URL.');
|
||||
scope.done();
|
||||
});
|
||||
|
||||
it('returns "Permission denied" on 403', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(403, { message: 'Forbidden' });
|
||||
|
||||
const [success, message] = await provider.test();
|
||||
expect(success).toBe(false);
|
||||
expect(message).toBe(
|
||||
'Permission denied. Verify the machine identity has access to this project.',
|
||||
);
|
||||
scope.done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('doConnect with Universal Auth', () => {
|
||||
it('logs in with clientId/clientSecret and tests the workspace with the issued token', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login', {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
})
|
||||
.reply(200, universalAuthLoginResponse('issued-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.matchHeader('authorization', 'Bearer issued-token')
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('connected');
|
||||
scope.done();
|
||||
});
|
||||
|
||||
it('transitions to error state when login fails', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(401, { message: 'Invalid client credentials' });
|
||||
|
||||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('error');
|
||||
scope.done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('caches secrets returned from the v4 list endpoint', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const connectScope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('connect-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
await provider.connect();
|
||||
connectScope.done();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query({
|
||||
projectId: PROJECT_ID,
|
||||
environment: ENVIRONMENT,
|
||||
secretPath: SECRET_PATH,
|
||||
})
|
||||
.matchHeader('authorization', 'Bearer connect-token')
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse([
|
||||
{ secretKey: 'API_KEY', secretValue: 'secret-value' },
|
||||
{ secretKey: 'DB_PASSWORD', secretValue: 'hunter2' },
|
||||
]),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('API_KEY')).toBe('secret-value');
|
||||
expect(provider.getSecret('DB_PASSWORD')).toBe('hunter2');
|
||||
expect(provider.hasSecret('API_KEY')).toBe(true);
|
||||
expect(provider.hasSecret('UNKNOWN')).toBe(false);
|
||||
expect(provider.getSecretNames().sort()).toEqual(['API_KEY', 'DB_PASSWORD']);
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('produces an empty cache when no secrets are returned', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const connectScope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('connect-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
await provider.connect();
|
||||
connectScope.done();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(200, listSecretsResponse([]));
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecretNames()).toHaveLength(0);
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('re-authenticates and retries once when the token is rejected during update', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const connectScope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('first-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
await provider.connect();
|
||||
connectScope.done();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.matchHeader('authorization', 'Bearer first-token')
|
||||
.reply(401, { message: 'Token expired' })
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('refreshed-token'))
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.matchHeader('authorization', 'Bearer refreshed-token')
|
||||
.reply(200, listSecretsResponse([{ secretKey: 'KEY', secretValue: 'val' }]));
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('KEY')).toBe('val');
|
||||
updateScope.done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update with imports', () => {
|
||||
const makeImport = (
|
||||
secrets: Array<{ secretKey: string; secretValue: string }>,
|
||||
secretPath = '/imported',
|
||||
environment = ENVIRONMENT,
|
||||
): ImportFixture => ({ secretPath, environment, secrets });
|
||||
|
||||
async function setupConnectedProvider() {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const connectScope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('connect-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse());
|
||||
|
||||
await provider.connect();
|
||||
connectScope.done();
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
it('caches secrets from imports alongside top-level secrets', async () => {
|
||||
const provider = await setupConnectedProvider();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse(
|
||||
[{ secretKey: 'API_KEY', secretValue: 'top-value' }],
|
||||
[
|
||||
makeImport([
|
||||
{ secretKey: 'IMPORTED_KEY', secretValue: 'from-import' },
|
||||
{ secretKey: 'ANOTHER', secretValue: 'also' },
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('API_KEY')).toBe('top-value');
|
||||
expect(provider.getSecret('IMPORTED_KEY')).toBe('from-import');
|
||||
expect(provider.getSecret('ANOTHER')).toBe('also');
|
||||
expect(provider.getSecretNames().sort()).toEqual(['ANOTHER', 'API_KEY', 'IMPORTED_KEY']);
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('prefers top-level secrets over imports when keys collide', async () => {
|
||||
const provider = await setupConnectedProvider();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse(
|
||||
[{ secretKey: 'SHARED_KEY', secretValue: 'wins' }],
|
||||
[makeImport([{ secretKey: 'SHARED_KEY', secretValue: 'loses' }])],
|
||||
),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('SHARED_KEY')).toBe('wins');
|
||||
expect(provider.getSecretNames()).toEqual(['SHARED_KEY']);
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('keeps the value from the first import when later imports define the same key', async () => {
|
||||
const provider = await setupConnectedProvider();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse(
|
||||
[],
|
||||
[
|
||||
makeImport([{ secretKey: 'DUP', secretValue: 'first' }], '/imported-a'),
|
||||
makeImport([{ secretKey: 'DUP', secretValue: 'second' }], '/imported-b'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('DUP')).toBe('first');
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('caches keys sourced only from imports when top-level secrets is empty', async () => {
|
||||
const provider = await setupConnectedProvider();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse(
|
||||
[],
|
||||
[
|
||||
makeImport([{ secretKey: 'FROM_A', secretValue: 'a' }], '/imported-a'),
|
||||
makeImport([{ secretKey: 'FROM_B', secretValue: 'b' }], '/imported-b'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('FROM_A')).toBe('a');
|
||||
expect(provider.getSecret('FROM_B')).toBe('b');
|
||||
expect(provider.getSecretNames().sort()).toEqual(['FROM_A', 'FROM_B']);
|
||||
updateScope.done();
|
||||
});
|
||||
|
||||
it('handles imports with empty secrets arrays without affecting the cache', async () => {
|
||||
const provider = await setupConnectedProvider();
|
||||
|
||||
const updateScope = nock(SITE_URL)
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(
|
||||
200,
|
||||
listSecretsResponse(
|
||||
[],
|
||||
[
|
||||
makeImport([], '/empty'),
|
||||
makeImport([{ secretKey: 'REAL', secretValue: 'value' }], '/has-secrets'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await provider.update();
|
||||
|
||||
expect(provider.getSecret('REAL')).toBe('value');
|
||||
expect(provider.getSecretNames()).toEqual(['REAL']);
|
||||
updateScope.done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('clears cached secrets and the in-flight token', async () => {
|
||||
const provider = new InfisicalProvider(logger);
|
||||
await provider.init(universalAuthSettings);
|
||||
|
||||
const scope = nock(SITE_URL)
|
||||
.post('/api/v1/auth/universal-auth/login')
|
||||
.reply(200, universalAuthLoginResponse('connect-token'))
|
||||
.get(`/api/v1/workspace/${PROJECT_ID}`)
|
||||
.reply(200, workspaceResponse())
|
||||
.get('/api/v4/secrets')
|
||||
.query(true)
|
||||
.reply(200, listSecretsResponse([{ secretKey: 'KEY', secretValue: 'val' }]));
|
||||
|
||||
await provider.connect();
|
||||
await provider.update();
|
||||
expect(provider.getSecretNames()).toContain('KEY');
|
||||
|
||||
await provider.disconnect();
|
||||
|
||||
expect(provider.getSecretNames()).toHaveLength(0);
|
||||
expect(provider.hasSecret('KEY')).toBe(false);
|
||||
scope.done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,47 +1,56 @@
|
|||
import type InfisicalClient from 'infisical-node';
|
||||
import { UnexpectedError, type IDataObject, type INodeProperties } from 'n8n-workflow';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { type INodeProperties, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { SecretsProvider } from '../types';
|
||||
import { DOCS_HELP_NOTICE } from '../constants';
|
||||
import type { SecretsProviderSettings } from '../types';
|
||||
import { SecretsProvider } from '../types';
|
||||
|
||||
export interface InfisicalSettings {
|
||||
token: string;
|
||||
type InfisicalAuthMethod = 'universalAuth';
|
||||
|
||||
interface InfisicalSettings {
|
||||
siteURL: string;
|
||||
cacheTTL: number;
|
||||
debug: boolean;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
authMethod: InfisicalAuthMethod;
|
||||
|
||||
// Universal Auth
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface InfisicalUniversalAuthLoginResponse {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
accessTokenMaxTTL: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
interface InfisicalSecret {
|
||||
secretName: string;
|
||||
secretValue?: string;
|
||||
secretKey: string;
|
||||
secretValue: string;
|
||||
}
|
||||
|
||||
interface InfisicalServiceToken {
|
||||
environment?: string;
|
||||
scopes?: Array<{ environment: string; path: string }>;
|
||||
interface InfisicalImport {
|
||||
secrets: InfisicalSecret[];
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
interface InfisicalListSecretsResponse {
|
||||
secrets: InfisicalSecret[];
|
||||
imports: InfisicalImport[];
|
||||
}
|
||||
|
||||
const TOKEN_REFRESH_LEEWAY_SECONDS = 60;
|
||||
const MIN_REFRESH_DELAY_MS = 60 * 1000;
|
||||
|
||||
export class InfisicalProvider extends SecretsProvider {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName:
|
||||
'<h2>Important information about our infisical integration</h2><br>From the <b>30th July, 2024</b>, we will no longer be supporting new connections to inifiscal secrets vault using service tokens. Existing service tokens will remain usable until <b>July, 2025</b>. After that period, we will be removing support for Infisical from our external secrets integrations. You can find out more information about this change on <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">our docs</a>',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Service Token',
|
||||
name: 'token',
|
||||
type: 'string',
|
||||
hint: 'The Infisical Service Token with read access',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. st.64ae963e1874ea.374226a166439dce.39557e4a1b7bdd82',
|
||||
noDataExpression: true,
|
||||
typeOptions: { password: true },
|
||||
},
|
||||
DOCS_HELP_NOTICE,
|
||||
{
|
||||
displayName: 'Site URL',
|
||||
name: 'siteURL',
|
||||
|
|
@ -52,6 +61,76 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
placeholder: 'https://app.infisical.com',
|
||||
default: 'https://app.infisical.com',
|
||||
},
|
||||
{
|
||||
displayName: 'Project ID',
|
||||
name: 'projectId',
|
||||
type: 'string',
|
||||
hint: 'The Infisical project to read secrets from',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
placeholder: 'e.g. 7c1cbe9c-3f1b-4a92-b3a2-9d5e2f1c8a4b',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Environment',
|
||||
name: 'environment',
|
||||
type: 'string',
|
||||
hint: 'Environment slug (e.g. dev, staging, prod)',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
placeholder: 'dev',
|
||||
default: 'dev',
|
||||
},
|
||||
{
|
||||
displayName: 'Secret Path',
|
||||
name: 'secretPath',
|
||||
type: 'string',
|
||||
hint: 'The path within the project to read secrets from',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
placeholder: '/',
|
||||
default: '/',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication Method',
|
||||
name: 'authMethod',
|
||||
type: 'options',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
options: [{ name: 'Universal Auth', value: 'universalAuth' }],
|
||||
default: 'universalAuth',
|
||||
},
|
||||
|
||||
// Universal Auth
|
||||
{
|
||||
displayName: 'Client ID',
|
||||
name: 'clientId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
placeholder: 'e.g. 8a7b1f1c-9f2a-4d6c-bf1a-2c4e6f8b1d3a',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authMethod: ['universalAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Client Secret',
|
||||
name: 'clientSecret',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
noDataExpression: true,
|
||||
placeholder: '***************',
|
||||
typeOptions: { password: true },
|
||||
displayOptions: {
|
||||
show: {
|
||||
authMethod: ['universalAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
displayName = 'Infisical';
|
||||
|
|
@ -60,87 +139,203 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
|
||||
private cachedSecrets: Record<string, string> = {};
|
||||
|
||||
private client: InfisicalClient;
|
||||
|
||||
private settings: InfisicalSettings;
|
||||
|
||||
private environment: string;
|
||||
private http: AxiosInstance;
|
||||
|
||||
private currentToken: string | null = null;
|
||||
|
||||
private tokenExpiresAt: number | null = null;
|
||||
|
||||
private refreshTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
private refreshAbort = new AbortController();
|
||||
|
||||
constructor(readonly logger = Container.get(Logger)) {
|
||||
super();
|
||||
this.logger = this.logger.scoped('external-secrets');
|
||||
}
|
||||
|
||||
async init(settings: SecretsProviderSettings): Promise<void> {
|
||||
this.settings = settings.settings as unknown as InfisicalSettings;
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new UnexpectedError('Updated attempted on Infisical when initialization failed');
|
||||
}
|
||||
if (!(await this.test())[0]) {
|
||||
throw new UnexpectedError('Infisical provider test failed during update');
|
||||
}
|
||||
const secrets = (await this.client.getAllSecrets({
|
||||
environment: this.environment,
|
||||
path: '/',
|
||||
attachToProcessEnv: false,
|
||||
includeImports: true,
|
||||
})) as InfisicalSecret[];
|
||||
const newCache = Object.fromEntries(
|
||||
secrets.map((s) => [s.secretName, s.secretValue]),
|
||||
) as Record<string, string>;
|
||||
if (Object.keys(newCache).length === 1 && '' in newCache) {
|
||||
this.cachedSecrets = {};
|
||||
} else {
|
||||
this.cachedSecrets = newCache;
|
||||
}
|
||||
const baseURL = new URL(this.settings.siteURL);
|
||||
|
||||
this.http = axios.create({ baseURL: baseURL.toString() });
|
||||
this.http.interceptors.request.use((config) => {
|
||||
if (this.currentToken) {
|
||||
config.headers.Authorization = `Bearer ${this.currentToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
this.logger.debug('Infisical provider initialized');
|
||||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
const { default: InfisicalClientClass } = await import('infisical-node');
|
||||
this.client = new InfisicalClientClass(this.settings);
|
||||
this.refreshAbort = new AbortController();
|
||||
|
||||
const [testSuccess] = await this.test();
|
||||
if (this.settings.authMethod === 'universalAuth') {
|
||||
if (!this.settings.clientId || !this.settings.clientSecret) {
|
||||
throw new UnexpectedError('Client ID and Client Secret are required for Universal Auth');
|
||||
}
|
||||
await this.loginUniversalAuth();
|
||||
}
|
||||
|
||||
const [testSuccess, testMessage] = await this.test();
|
||||
if (!testSuccess) {
|
||||
throw new Error('Connection test failed');
|
||||
throw new Error(testMessage ?? 'Connection test failed');
|
||||
}
|
||||
|
||||
this.environment = await this.getEnvironment();
|
||||
}
|
||||
|
||||
async getEnvironment(): Promise<string> {
|
||||
const { getServiceTokenData } = await import('infisical-node/lib/api/serviceTokenData');
|
||||
const serviceTokenData = (await getServiceTokenData(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
this.client.clientConfig,
|
||||
)) as InfisicalServiceToken;
|
||||
if (serviceTokenData.environment) {
|
||||
return serviceTokenData.environment;
|
||||
}
|
||||
if (serviceTokenData.scopes) {
|
||||
return serviceTokenData.scopes[0].environment;
|
||||
}
|
||||
throw new UnexpectedError("Couldn't find environment for Infisical");
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
if (!this.client) {
|
||||
return [false, 'Client not initialized'];
|
||||
}
|
||||
try {
|
||||
const { populateClientWorkspaceConfigsHelper } = await import(
|
||||
'infisical-node/lib/helpers/key'
|
||||
);
|
||||
await populateClientWorkspaceConfigsHelper(this.client.clientConfig);
|
||||
return [true];
|
||||
} catch (e) {
|
||||
return [false];
|
||||
}
|
||||
this.setupTokenRefresh();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
//
|
||||
if (this.refreshTimeout !== null) {
|
||||
clearTimeout(this.refreshTimeout);
|
||||
this.refreshTimeout = null;
|
||||
}
|
||||
this.refreshAbort.abort();
|
||||
this.currentToken = null;
|
||||
this.tokenExpiresAt = null;
|
||||
this.cachedSecrets = {};
|
||||
}
|
||||
|
||||
getSecret(name: string): IDataObject {
|
||||
return this.cachedSecrets[name] as unknown as IDataObject;
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
try {
|
||||
const resp = await this.http.get(
|
||||
`/api/v1/workspace/${encodeURIComponent(this.settings.projectId)}`,
|
||||
{ validateStatus: () => true },
|
||||
);
|
||||
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
return [true];
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
return [false, 'Invalid credentials'];
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return [
|
||||
false,
|
||||
'Permission denied. Verify the machine identity has access to this project.',
|
||||
];
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return [false, 'Project not found. Check the Project ID and Site URL.'];
|
||||
}
|
||||
|
||||
return [false, `Unexpected response from Infisical (status ${resp.status}).`];
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.code === 'ECONNREFUSED') {
|
||||
return [false, 'Connection refused. Check the Site URL.'];
|
||||
}
|
||||
return [false, error instanceof Error ? error.message : 'Connection test failed'];
|
||||
}
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
if (!this.currentToken) {
|
||||
throw new UnexpectedError('Update attempted on Infisical before authentication');
|
||||
}
|
||||
|
||||
await this.ensureTokenFresh();
|
||||
|
||||
try {
|
||||
this.cacheSecrets(await this.fetchSecrets());
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
this.logger.debug('Infisical token rejected during update; re-authenticating and retrying');
|
||||
await this.loginUniversalAuth();
|
||||
this.cacheSecrets(await this.fetchSecrets());
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSecrets(): Promise<InfisicalListSecretsResponse> {
|
||||
const resp = await this.http.get<InfisicalListSecretsResponse>('/api/v4/secrets', {
|
||||
params: {
|
||||
projectId: this.settings.projectId,
|
||||
environment: this.settings.environment,
|
||||
secretPath: this.settings.secretPath,
|
||||
},
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
private dedupeSecrets(secrets: InfisicalSecret[], imports: InfisicalImport[]): InfisicalSecret[] {
|
||||
const dedupedSecrets = new Map<string, InfisicalSecret>();
|
||||
secrets.forEach((s) => {
|
||||
dedupedSecrets.set(s.secretKey, s);
|
||||
});
|
||||
imports.forEach((i) => {
|
||||
i.secrets.forEach((s) => {
|
||||
if (!dedupedSecrets.has(s.secretKey)) {
|
||||
dedupedSecrets.set(s.secretKey, s);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(dedupedSecrets.values());
|
||||
}
|
||||
|
||||
private cacheSecrets(data: InfisicalListSecretsResponse): void {
|
||||
const dedupedSecrets = this.dedupeSecrets(data.secrets, data.imports);
|
||||
this.cachedSecrets = Object.fromEntries(
|
||||
dedupedSecrets.map((s) => [s.secretKey, s.secretValue]),
|
||||
);
|
||||
this.logger.debug(
|
||||
`Infisical provider cached ${Object.keys(this.cachedSecrets).length} secrets`,
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureTokenFresh(): Promise<void> {
|
||||
if (this.tokenExpiresAt === null) return;
|
||||
if (Date.now() < this.tokenExpiresAt) return;
|
||||
await this.loginUniversalAuth();
|
||||
}
|
||||
|
||||
private async loginUniversalAuth(): Promise<void> {
|
||||
const resp = await this.http.post<InfisicalUniversalAuthLoginResponse>(
|
||||
'/api/v1/auth/universal-auth/login',
|
||||
{
|
||||
clientId: this.settings.clientId,
|
||||
clientSecret: this.settings.clientSecret,
|
||||
},
|
||||
);
|
||||
|
||||
this.currentToken = resp.data.accessToken;
|
||||
this.tokenExpiresAt =
|
||||
Date.now() + Math.max(resp.data.expiresIn - TOKEN_REFRESH_LEEWAY_SECONDS, 60) * 1000;
|
||||
}
|
||||
|
||||
private setupTokenRefresh(): void {
|
||||
if (this.refreshTimeout !== null) {
|
||||
clearTimeout(this.refreshTimeout);
|
||||
this.refreshTimeout = null;
|
||||
}
|
||||
if (this.tokenExpiresAt === null) return;
|
||||
|
||||
const remaining = this.tokenExpiresAt - Date.now();
|
||||
const refreshIn = Math.max(remaining / 2, MIN_REFRESH_DELAY_MS);
|
||||
this.refreshTimeout = setTimeout(this.tokenRefresh, refreshIn);
|
||||
}
|
||||
|
||||
private tokenRefresh = async (): Promise<void> => {
|
||||
if (this.refreshAbort.signal.aborted) return;
|
||||
try {
|
||||
await this.loginUniversalAuth();
|
||||
if (this.refreshAbort.signal.aborted) return;
|
||||
this.setupTokenRefresh();
|
||||
} catch {
|
||||
this.logger.error('Failed to refresh Infisical token. Attempting reconnect.');
|
||||
void this.connect();
|
||||
}
|
||||
};
|
||||
|
||||
getSecret(name: string): string | undefined {
|
||||
return this.cachedSecrets[name];
|
||||
}
|
||||
|
||||
getSecretNames(): string[] {
|
||||
|
|
|
|||
|
|
@ -3372,7 +3372,6 @@
|
|||
"settings.externalSecrets.actionBox.description.link": "More info",
|
||||
"settings.externalSecrets.actionBox.buttonText": "See plans",
|
||||
"settings.externalSecrets.card.setUp": "Set Up",
|
||||
"settings.externalSecrets.card.deprecated": "deprecated",
|
||||
"settings.externalSecrets.card.secretsCount": "{count} secret | {count} secrets",
|
||||
"settings.externalSecrets.card.connectedAt": "Connected {date}",
|
||||
"settings.externalSecrets.card.connected": "Enabled",
|
||||
|
|
|
|||
|
|
@ -12,14 +12,7 @@ import { DateTime } from 'luxon';
|
|||
import { computed, nextTick, onMounted, toRef } from 'vue';
|
||||
import { isDateObject } from '@/app/utils/typeGuards';
|
||||
|
||||
import {
|
||||
N8nActionToggle,
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nCard,
|
||||
N8nIcon,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { N8nActionToggle, N8nButton, N8nCard, N8nText } from '@n8n/design-system';
|
||||
const props = defineProps<{
|
||||
provider: ExternalSecretsProvider;
|
||||
}>();
|
||||
|
|
@ -142,12 +135,6 @@ async function onActionDropdownClick(id: string) {
|
|||
</span>
|
||||
</N8nText>
|
||||
</div>
|
||||
<div v-if="provider.name === 'infisical'" :class="$style.deprecationWarning">
|
||||
<N8nIcon :class="$style['warningTriangle']" icon="triangle-alert" />
|
||||
<N8nBadge class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
||||
{{ i18n.baseText('settings.externalSecrets.card.deprecated') }}
|
||||
</N8nBadge>
|
||||
</div>
|
||||
<div v-if="canConnect" :class="$style.cardActions">
|
||||
<ExternalSecretsProviderConnectionSwitch
|
||||
:provider="provider"
|
||||
|
|
@ -198,13 +185,4 @@ async function onActionDropdownClick(id: string) {
|
|||
align-items: center;
|
||||
margin-left: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.deprecationWarning {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.warningTriangle {
|
||||
color: var(--color--warning);
|
||||
margin-right: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -206,69 +206,6 @@ describe('useConnectionModal', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('infisical deprecation', () => {
|
||||
beforeEach(() => {
|
||||
mockModuleSettings['external-secrets'] = {
|
||||
multipleConnections: true,
|
||||
forProjects: true,
|
||||
roleBasedAccess: false,
|
||||
};
|
||||
});
|
||||
it('should not provide option to create new infisical connection if multipleConnections is enabled', () => {
|
||||
const providerTypesWithInfisical = ref([
|
||||
...mockProviderTypes,
|
||||
{
|
||||
type: 'infisical',
|
||||
displayName: 'Infisical',
|
||||
icon: 'infisical',
|
||||
properties: [],
|
||||
} as SecretProviderTypeResponse,
|
||||
]);
|
||||
|
||||
const { providerTypeOptions } = useConnectionModal({
|
||||
...defaultOptions,
|
||||
providerTypes: providerTypesWithInfisical,
|
||||
});
|
||||
|
||||
expect(providerTypeOptions.value.find((opt) => opt.value === 'infisical')).toBeUndefined();
|
||||
expect(providerTypeOptions.value.length).toEqual(1);
|
||||
expect(providerTypeOptions.value[0].value).toEqual('awsSecretsManager');
|
||||
});
|
||||
|
||||
it('should still set selectedProviderType to infisical on existing infisical connection', async () => {
|
||||
const providerTypesWithInfisical = ref([
|
||||
...mockProviderTypes,
|
||||
{
|
||||
type: 'infisical',
|
||||
displayName: 'Infisical',
|
||||
icon: 'infisical',
|
||||
properties: [],
|
||||
} as SecretProviderTypeResponse,
|
||||
]);
|
||||
|
||||
// Mock existing infisical connection
|
||||
mockConnection.getConnection.mockResolvedValue({
|
||||
id: 'infisical-id',
|
||||
name: 'infisical-connection',
|
||||
type: 'infisical',
|
||||
state: 'connected',
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
providerTypes: providerTypesWithInfisical,
|
||||
providerKey: ref('infisical-key'),
|
||||
};
|
||||
|
||||
const { selectedProviderType, loadConnection } = useConnectionModal(options);
|
||||
|
||||
await loadConnection();
|
||||
|
||||
expect(selectedProviderType.value?.type).toBe('infisical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should detect unsaved changes', async () => {
|
||||
const options = { ...defaultOptions, providerKey: ref('testKey') };
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import { getResourcePermissions } from '@n8n/permissions';
|
|||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
|
||||
import { isComponentPublicInstance } from '@/app/utils/typeGuards';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
|
||||
interface UseConnectionModalOptions {
|
||||
providerTypes: Ref<SecretProviderTypeResponse[]>;
|
||||
existingProviderNames?: Ref<string[]>;
|
||||
|
|
@ -41,7 +39,6 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
|
|||
const rbacStore = useRBACStore();
|
||||
const toast = useToast();
|
||||
const projectsStore = useProjectsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// State
|
||||
const providerKey = ref<string | undefined>(options.providerKey?.value);
|
||||
|
|
@ -166,22 +163,12 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
|
|||
|
||||
const isEditMode = computed(() => !!providerKey.value);
|
||||
|
||||
const providerTypeOptions = computed(() => {
|
||||
const prvdrTypeOptions = providerTypes.value.map((type) => ({
|
||||
const providerTypeOptions = computed(() =>
|
||||
providerTypes.value.map((type) => ({
|
||||
label: type.displayName,
|
||||
value: type.type,
|
||||
}));
|
||||
|
||||
if (settingsStore.moduleSettings['external-secrets']?.multipleConnections) {
|
||||
// infisical has been deprecated for a long time.
|
||||
// In order to be able to fully remove the code for it
|
||||
// we are no longer showing users the option to create connections to infisical.
|
||||
// Any previously existing connections will keep working for now.
|
||||
return prvdrTypeOptions.filter((opt) => opt.value !== 'infisical');
|
||||
}
|
||||
|
||||
return prvdrTypeOptions;
|
||||
});
|
||||
})),
|
||||
);
|
||||
|
||||
const settingsUpdated = computed(() => {
|
||||
return Object.keys(connectionSettings.value).some((key) => {
|
||||
|
|
|
|||
|
|
@ -3197,9 +3197,6 @@ importers:
|
|||
http-proxy-middleware:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
infisical-node:
|
||||
specifier: 1.3.0
|
||||
version: 1.3.0
|
||||
ioredis:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2
|
||||
|
|
@ -15531,10 +15528,6 @@ packages:
|
|||
infer-owner@1.0.4:
|
||||
resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==}
|
||||
|
||||
infisical-node@1.3.0:
|
||||
resolution: {integrity: sha512-tTnnExRAO/ZyqiRdnSlBisErNToYWgtunMWh+8opClEt5qjX7l6HC/b4oGo2AuR2Pf41IR+oqo+dzkM1TCvlUA==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
inflected@2.1.0:
|
||||
resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==}
|
||||
|
||||
|
|
@ -20526,15 +20519,9 @@ packages:
|
|||
turndown@7.2.2:
|
||||
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
|
||||
|
||||
tweetnacl-util@0.15.1:
|
||||
resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -34998,16 +34985,6 @@ snapshots:
|
|||
infer-owner@1.0.4:
|
||||
optional: true
|
||||
|
||||
infisical-node@1.3.0:
|
||||
dependencies:
|
||||
axios: 1.16.1(debug@4.4.3)
|
||||
dotenv: 16.6.1
|
||||
tweetnacl: 1.0.3
|
||||
tweetnacl-util: 0.15.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
inflected@2.1.0: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
|
|
@ -41236,12 +41213,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@mixmark-io/domino': 2.2.0
|
||||
|
||||
tweetnacl-util@0.15.1: {}
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user