Merge pull request #4667 from Infisical/feat/in-platform-vault-migration-tooling
feat: in-platform migration tooling for Vault policies + scaffolding
8
backend/src/@types/knex.d.ts
vendored
@@ -518,6 +518,9 @@ import {
|
||||
TUsers,
|
||||
TUsersInsert,
|
||||
TUsersUpdate,
|
||||
TVaultExternalMigrationConfigs,
|
||||
TVaultExternalMigrationConfigsInsert,
|
||||
TVaultExternalMigrationConfigsUpdate,
|
||||
TWebhooks,
|
||||
TWebhooksInsert,
|
||||
TWebhooksUpdate,
|
||||
@@ -1345,5 +1348,10 @@ declare module "knex/types/tables" {
|
||||
TAdditionalPrivilegesInsert,
|
||||
TAdditionalPrivilegesUpdate
|
||||
>;
|
||||
[TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType<
|
||||
TVaultExternalMigrationConfigs,
|
||||
TVaultExternalMigrationConfigsInsert,
|
||||
TVaultExternalMigrationConfigsUpdate
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.VaultExternalMigrationConfig))) {
|
||||
await knex.schema.createTable(TableName.VaultExternalMigrationConfig, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("orgId").notNullable();
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
|
||||
t.string("namespace").notNullable();
|
||||
|
||||
t.uuid("connectionId");
|
||||
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
t.unique(["orgId", "namespace"]);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig);
|
||||
await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig);
|
||||
}
|
||||
@@ -176,5 +176,6 @@ export * from "./user-aliases";
|
||||
export * from "./user-encryption-keys";
|
||||
export * from "./user-group-membership";
|
||||
export * from "./users";
|
||||
export * from "./vault-external-migration-configs";
|
||||
export * from "./webhooks";
|
||||
export * from "./workflow-integrations";
|
||||
|
||||
@@ -203,7 +203,9 @@ export enum TableName {
|
||||
PamFolder = "pam_folders",
|
||||
PamResource = "pam_resources",
|
||||
PamAccount = "pam_accounts",
|
||||
PamSession = "pam_sessions"
|
||||
PamSession = "pam_sessions",
|
||||
|
||||
VaultExternalMigrationConfig = "vault_external_migration_configs"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||
|
||||
26
backend/src/db/schemas/vault-external-migration-configs.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const VaultExternalMigrationConfigsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
orgId: z.string().uuid(),
|
||||
namespace: z.string(),
|
||||
connectionId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TVaultExternalMigrationConfigs = z.infer<typeof VaultExternalMigrationConfigsSchema>;
|
||||
export type TVaultExternalMigrationConfigsInsert = Omit<
|
||||
z.input<typeof VaultExternalMigrationConfigsSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TVaultExternalMigrationConfigsUpdate = Partial<
|
||||
Omit<z.input<typeof VaultExternalMigrationConfigsSchema>, TImmutableDBKeys>
|
||||
>;
|
||||
@@ -176,6 +176,7 @@ import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-gr
|
||||
import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
|
||||
import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
|
||||
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
|
||||
import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal";
|
||||
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
|
||||
import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal";
|
||||
import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
|
||||
@@ -534,6 +535,8 @@ export const registerRoutes = async (
|
||||
const membershipRoleDAL = membershipRoleDALFactory(db);
|
||||
const roleDAL = roleDALFactory(db);
|
||||
|
||||
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
|
||||
|
||||
const eventBusService = eventBusFactory(server.redis);
|
||||
const sseService = sseServiceFactory(eventBusService, server.redis);
|
||||
|
||||
@@ -1882,13 +1885,6 @@ export const registerRoutes = async (
|
||||
notificationService
|
||||
});
|
||||
|
||||
const migrationService = externalMigrationServiceFactory({
|
||||
externalMigrationQueue,
|
||||
userDAL,
|
||||
permissionService,
|
||||
gatewayService
|
||||
});
|
||||
|
||||
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
||||
permissionService,
|
||||
licenseService,
|
||||
@@ -2205,6 +2201,18 @@ export const registerRoutes = async (
|
||||
kmsService
|
||||
});
|
||||
|
||||
const migrationService = externalMigrationServiceFactory({
|
||||
externalMigrationQueue,
|
||||
userDAL,
|
||||
permissionService,
|
||||
gatewayService,
|
||||
kmsService,
|
||||
appConnectionService,
|
||||
vaultExternalMigrationConfigDAL,
|
||||
secretService,
|
||||
auditLogService
|
||||
});
|
||||
|
||||
// setup the communication with license key server
|
||||
await licenseService.init();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
VaultImportStatus,
|
||||
VaultMappingType
|
||||
} from "@app/services/external-migration/external-migration-types";
|
||||
|
||||
@@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
|
||||
return { enabled };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/configs",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
configs: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
namespace: z.string(),
|
||||
connectionId: z.string().nullish(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const configs = await server.services.migration.getVaultExternalMigrationConfigs({
|
||||
actor: req.permission
|
||||
});
|
||||
|
||||
return { configs };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/vault/configs",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
connectionId: z.string(),
|
||||
namespace: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
config: z.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
namespace: z.string(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const config = await server.services.migration.createVaultExternalMigration({
|
||||
...req.body,
|
||||
actor: req.permission
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PUT",
|
||||
url: "/vault/configs/:id",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
body: z.object({
|
||||
connectionId: z.string(),
|
||||
namespace: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
config: z.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
namespace: z.string(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const config = await server.services.migration.updateVaultExternalMigration({
|
||||
id: req.params.id,
|
||||
...req.body,
|
||||
actor: req.permission
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/vault/configs/:id",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
config: z.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
namespace: z.string(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const config = await server.services.migration.deleteVaultExternalMigration({
|
||||
id: req.params.id,
|
||||
actor: req.permission
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/namespaces",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
namespaces: z.array(z.object({ id: z.string(), name: z.string() }))
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const namespaces = await server.services.migration.getVaultNamespaces({
|
||||
actor: req.permission
|
||||
});
|
||||
|
||||
return { namespaces };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/policies",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
namespace: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
policies: z.array(z.object({ name: z.string(), rules: z.string() }))
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const policies = await server.services.migration.getVaultPolicies({
|
||||
actor: req.permission,
|
||||
namespace: req.query.namespace
|
||||
});
|
||||
|
||||
return { policies };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/mounts",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
namespace: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() }))
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const mounts = await server.services.migration.getVaultMounts({
|
||||
actor: req.permission,
|
||||
namespace: req.query.namespace
|
||||
});
|
||||
|
||||
return { mounts };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/auth-mounts",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
namespace: z.string(),
|
||||
authType: z.string().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
mounts: z.array(z.object({ path: z.string(), type: z.string() }))
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const mounts = await server.services.migration.getVaultAuthMounts({
|
||||
actor: req.permission,
|
||||
namespace: req.query.namespace,
|
||||
authType: req.query.authType
|
||||
});
|
||||
|
||||
return { mounts };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/vault/import-secrets",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
projectId: z.string(),
|
||||
environment: z.string(),
|
||||
secretPath: z.string(),
|
||||
vaultNamespace: z.string(),
|
||||
vaultSecretPath: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.nativeEnum(VaultImportStatus)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.migration.importVaultSecrets({
|
||||
actor: req.permission,
|
||||
auditLogInfo: req.auditLogInfo,
|
||||
...req.body
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/secret-paths",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
namespace: z.string(),
|
||||
mountPath: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secretPaths: z.string().array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const secretPaths = await server.services.migration.getVaultSecretPaths({
|
||||
actor: req.permission,
|
||||
namespace: req.query.namespace,
|
||||
mountPath: req.query.mountPath
|
||||
});
|
||||
|
||||
return { secretPaths };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/vault/auth-roles/kubernetes",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
namespace: z.string(),
|
||||
mountPath: z.string()
|
||||
}),
|
||||
|
||||
response: {
|
||||
200: z.object({
|
||||
roles: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
mountPath: z.string(),
|
||||
bound_service_account_names: z.array(z.string()),
|
||||
bound_service_account_namespaces: z.array(z.string()),
|
||||
token_ttl: z.number().optional(),
|
||||
token_max_ttl: z.number().optional(),
|
||||
token_policies: z.array(z.string()).optional(),
|
||||
token_bound_cidrs: z.array(z.string()).optional(),
|
||||
token_explicit_max_ttl: z.number().optional(),
|
||||
token_no_default_policy: z.boolean().optional(),
|
||||
token_num_uses: z.number().optional(),
|
||||
token_period: z.number().optional(),
|
||||
token_type: z.string().optional(),
|
||||
audience: z.string().optional(),
|
||||
alias_name_source: z.string().optional(),
|
||||
config: z.object({
|
||||
kubernetes_host: z.string(),
|
||||
kubernetes_ca_cert: z.string().optional(),
|
||||
issuer: z.string().optional(),
|
||||
disable_iss_validation: z.boolean().optional(),
|
||||
disable_local_ca_jwt: z.boolean().optional()
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const roles = await server.services.migration.getVaultKubernetesAuthRoles({
|
||||
actor: req.permission,
|
||||
namespace: req.query.namespace,
|
||||
mountPath: req.query.mountPath
|
||||
});
|
||||
|
||||
return { roles };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod {
|
||||
AccessToken = "access-token",
|
||||
AppRole = "app-role"
|
||||
}
|
||||
|
||||
export enum HCVaultAuthType {
|
||||
Kubernetes = "kubernetes"
|
||||
}
|
||||
|
||||
@@ -12,8 +12,56 @@ import { logger } from "@app/lib/logger";
|
||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
||||
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
|
||||
import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
||||
import {
|
||||
THCVaultAuthMount,
|
||||
THCVaultAuthMountResponse,
|
||||
THCVaultConnection,
|
||||
THCVaultConnectionConfig,
|
||||
THCVaultKubernetesAuthConfig,
|
||||
THCVaultKubernetesAuthRole,
|
||||
THCVaultKubernetesAuthRoleWithConfig,
|
||||
THCVaultMount,
|
||||
THCVaultMountResponse
|
||||
} from "./hc-vault-connection-types";
|
||||
|
||||
// Concurrency limit for HC Vault API requests to avoid rate limiting
|
||||
const HC_VAULT_CONCURRENCY_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Creates a concurrency limiter that restricts the number of concurrent async operations
|
||||
* @param limit - Maximum number of concurrent operations
|
||||
* @returns A function that takes an async function and executes it with concurrency control
|
||||
*/
|
||||
const createConcurrencyLimiter = (limit: number) => {
|
||||
let activeCount = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
const next = () => {
|
||||
activeCount -= 1;
|
||||
if (queue.length > 0) {
|
||||
const resolve = queue.shift();
|
||||
resolve?.();
|
||||
}
|
||||
};
|
||||
|
||||
return async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
// If we're at the limit, wait in queue
|
||||
if (activeCount >= limit) {
|
||||
await new Promise<void>((resolve) => {
|
||||
queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
next();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
||||
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
|
||||
@@ -181,30 +229,573 @@ export const validateHCVaultConnectionCredentials = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const listHCVaultMounts = async (
|
||||
export const listHCVaultPolicies = async (
|
||||
namespace: string,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
try {
|
||||
const { data: listData } = await requestWithHCVaultGateway<{
|
||||
data: {
|
||||
policies: string[];
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/policy`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
const policyNames = listData.data.policies || [];
|
||||
|
||||
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
|
||||
|
||||
const policies = await Promise.all(
|
||||
policyNames.map((policyName) =>
|
||||
limiter(async () => {
|
||||
try {
|
||||
const { data: policyData } = await requestWithHCVaultGateway<{
|
||||
data: {
|
||||
name: string;
|
||||
rules: string;
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/policy/${policyName}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: policyData.data.name,
|
||||
rules: policyData.data.rules
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, `Unable to fetch policy details for ${policyName}`);
|
||||
return {
|
||||
name: policyName,
|
||||
rules: ""
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return policies;
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to list HC Vault policies");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list policies: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list policies from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listHCVaultNamespaces = async (
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
const currentNamespace = connection.credentials.namespace || "/";
|
||||
|
||||
// Helper function to fetch namespaces at a specific path
|
||||
const fetchNamespacesAtPath = async (namespacePath: string): Promise<string[] | null> => {
|
||||
try {
|
||||
const { data } = await requestWithHCVaultGateway<{
|
||||
data: {
|
||||
keys: string[];
|
||||
key_info?: {
|
||||
[key: string]: {
|
||||
id: string;
|
||||
path: string;
|
||||
custom_metadata?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/namespaces?list=true`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespacePath
|
||||
}
|
||||
});
|
||||
|
||||
return data.data.keys || [];
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError && error.response?.status === 404) {
|
||||
// No child namespaces at this path
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive function to get all namespaces at all depths with controlled parallelization
|
||||
const recursivelyGetAllNamespaces = async (
|
||||
parentPath: string,
|
||||
limiter: ReturnType<typeof createConcurrencyLimiter>
|
||||
): Promise<string[]> => {
|
||||
const childKeys = await fetchNamespacesAtPath(parentPath);
|
||||
|
||||
if (childKeys === null || childKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process namespaces in parallel with concurrency control
|
||||
const namespacesArrays = await Promise.all(
|
||||
childKeys.map((namespaceKey) =>
|
||||
limiter(async () => {
|
||||
// Remove trailing slash from the key
|
||||
const cleanNamespaceKey = namespaceKey.replace(/\/$/, "");
|
||||
|
||||
// Build the full path
|
||||
let fullNamespacePath: string;
|
||||
if (parentPath === "/") {
|
||||
fullNamespacePath = cleanNamespaceKey;
|
||||
} else {
|
||||
fullNamespacePath = `${parentPath}/${cleanNamespaceKey}`;
|
||||
}
|
||||
|
||||
// Recursively fetch child namespaces
|
||||
const childNamespaces = await recursivelyGetAllNamespaces(fullNamespacePath, limiter);
|
||||
|
||||
// Return this namespace and all its children
|
||||
return [fullNamespacePath, ...childNamespaces];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Flatten the arrays into a single array
|
||||
return namespacesArrays.flat();
|
||||
};
|
||||
|
||||
try {
|
||||
// Create concurrency limiter to avoid overwhelming the Vault instance
|
||||
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
|
||||
|
||||
// Get all namespaces starting from currentNamespace
|
||||
const childNamespaces = await recursivelyGetAllNamespaces(currentNamespace, limiter);
|
||||
|
||||
// Build the result array with full paths
|
||||
const namespaces = childNamespaces.map((path) => ({
|
||||
id: path,
|
||||
name: path
|
||||
}));
|
||||
|
||||
// Always include the current/root namespace
|
||||
namespaces.unshift({
|
||||
id: currentNamespace,
|
||||
name: currentNamespace
|
||||
});
|
||||
|
||||
return namespaces;
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to list HC Vault namespaces");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list namespaces: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list namespaces from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listHCVaultMounts = async (
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||
namespace?: string
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
const targetNamespace = namespace || connection.credentials.namespace;
|
||||
|
||||
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/mounts`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
||||
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
|
||||
}
|
||||
});
|
||||
|
||||
const mounts: string[] = [];
|
||||
const mounts: THCVaultMount[] = [];
|
||||
|
||||
// Filter for "kv" version 2 type only
|
||||
Object.entries(data.data).forEach(([path, mount]) => {
|
||||
if (mount.type === "kv" && mount.options?.version === "2") {
|
||||
mounts.push(path);
|
||||
}
|
||||
mounts.push({
|
||||
path,
|
||||
type: mount.type,
|
||||
version: mount.options?.version
|
||||
});
|
||||
});
|
||||
|
||||
return mounts;
|
||||
};
|
||||
|
||||
export const listHCVaultSecretPaths = async (
|
||||
namespace: string,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||
filterMountPath?: string
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise<string[] | null> => {
|
||||
try {
|
||||
let path: string;
|
||||
if (kvVersion === "2") {
|
||||
// For KV v2: /v1/{mount}/metadata/{path}?list=true
|
||||
path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
|
||||
} else {
|
||||
// For KV v1: /v1/{mount}/{path}?list=true
|
||||
path = secretPath ? `${mountPath}/${secretPath}` : mountPath;
|
||||
}
|
||||
|
||||
const { data } = await requestWithHCVaultGateway<{
|
||||
data: {
|
||||
keys: string[];
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${path}?list=true`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.data.keys;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive function to get all secret paths in a mount with controlled parallelization
|
||||
const recursivelyGetAllPaths = async (
|
||||
mountPath: string,
|
||||
kvVersion: "1" | "2",
|
||||
limiter: ReturnType<typeof createConcurrencyLimiter>,
|
||||
currentPath: string = ""
|
||||
): Promise<string[]> => {
|
||||
const paths = await getPaths(mountPath, currentPath, kvVersion);
|
||||
|
||||
if (paths === null || paths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process paths in parallel with concurrency control
|
||||
const secretPathsArrays = await Promise.all(
|
||||
paths.map((path) =>
|
||||
limiter(async () => {
|
||||
const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath;
|
||||
|
||||
if (path.endsWith("/")) {
|
||||
// it's a folder so we recurse into it
|
||||
return recursivelyGetAllPaths(mountPath, kvVersion, limiter, fullItemPath);
|
||||
}
|
||||
// it's a secret so we return it
|
||||
return [`${mountPath}/${fullItemPath}`];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Flatten the arrays into a single array
|
||||
return secretPathsArrays.flat();
|
||||
};
|
||||
|
||||
// Get all mounts
|
||||
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
|
||||
|
||||
// Filter for KV mounts (kv, kv-v1, kv-v2)
|
||||
let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
|
||||
|
||||
// If filterMountPath is provided, filter to only that mount
|
||||
if (filterMountPath) {
|
||||
const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash
|
||||
kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath);
|
||||
}
|
||||
|
||||
// Create concurrency limiter to avoid overwhelming the Vault instance
|
||||
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
|
||||
|
||||
// Collect all secret paths from all KV mounts in parallel
|
||||
const allSecretPathsArrays = await Promise.all(
|
||||
kvMounts.map(async (mount) => {
|
||||
const kvVersion = mount.version === "2" ? "2" : "1";
|
||||
const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash
|
||||
return recursivelyGetAllPaths(cleanMountPath, kvVersion, limiter);
|
||||
})
|
||||
);
|
||||
|
||||
// Flatten the arrays into a single array
|
||||
const allSecretPaths = allSecretPathsArrays.flat();
|
||||
|
||||
return allSecretPaths;
|
||||
};
|
||||
|
||||
export const getHCVaultSecretsForPath = async (
|
||||
namespace: string,
|
||||
secretPath: string,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
try {
|
||||
// Extract mount and path from the secretPath
|
||||
// secretPath format: {mount}/{path}
|
||||
const pathParts = secretPath.split("/");
|
||||
const mountPath = pathParts[0];
|
||||
const actualPath = pathParts.slice(1).join("/");
|
||||
|
||||
if (!mountPath || !actualPath) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid secret path format. Expected format: {mount}/{path}"
|
||||
});
|
||||
}
|
||||
|
||||
// Get mounts to determine KV version
|
||||
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
|
||||
const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath);
|
||||
|
||||
if (!mount) {
|
||||
throw new BadRequestError({
|
||||
message: `Mount '${mountPath}' not found in HashiCorp Vault`
|
||||
});
|
||||
}
|
||||
|
||||
const kvVersion = mount.version === "2" ? "2" : "1";
|
||||
|
||||
// Fetch secrets based on KV version
|
||||
if (kvVersion === "2") {
|
||||
// For KV v2: /v1/{mount}/data/{path}
|
||||
const { data } = await requestWithHCVaultGateway<{
|
||||
data: {
|
||||
data: Record<string, string>; // KV v2 has nested data structure
|
||||
metadata: {
|
||||
created_time: string;
|
||||
deletion_time: string;
|
||||
destroyed: boolean;
|
||||
version: number;
|
||||
};
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
// For KV v1: /v1/{mount}/{path}
|
||||
const { data } = await requestWithHCVaultGateway<{
|
||||
data: Record<string, string>; // KV v1 has flat data structure
|
||||
lease_duration: number;
|
||||
lease_id: string;
|
||||
renewable: boolean;
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${mountPath}/${actualPath}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.data;
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to fetch secrets from HC Vault path");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to fetch secrets: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to fetch secrets from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getHCVaultAuthMounts = async (
|
||||
namespace: string,
|
||||
authType: HCVaultAuthType | undefined,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
): Promise<THCVaultAuthMount[]> => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
try {
|
||||
const { data } = await requestWithHCVaultGateway<THCVaultAuthMountResponse>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/auth`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
});
|
||||
|
||||
const authMounts: THCVaultAuthMount[] = [];
|
||||
|
||||
Object.entries(data.data).forEach(([path, authMethod]) => {
|
||||
// If authType is specified, filter by it; otherwise, include all
|
||||
if (!authType || authMethod.type === authType) {
|
||||
authMounts.push({
|
||||
path,
|
||||
type: authMethod.type,
|
||||
description: authMethod.description,
|
||||
accessor: authMethod.accessor
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return authMounts;
|
||||
} catch (error: unknown) {
|
||||
const authTypeStr = authType || "all";
|
||||
logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`);
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getHCVaultKubernetesAuthRoles = async (
|
||||
namespace: string,
|
||||
mountPath: string,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
): Promise<THCVaultKubernetesAuthRoleWithConfig[]> => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
// Remove trailing slash from mount path
|
||||
const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath;
|
||||
|
||||
try {
|
||||
// 1. Get the Kubernetes auth configuration for this mount
|
||||
const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthConfig }>(
|
||||
connection,
|
||||
gatewayService,
|
||||
{
|
||||
url: `${instanceUrl}/v1/auth/${cleanMountPath}/config`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const kubernetesConfig = configResponse.data;
|
||||
|
||||
// 2. List all roles in this mount
|
||||
const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>(
|
||||
connection,
|
||||
gatewayService,
|
||||
{
|
||||
url: `${instanceUrl}/v1/auth/${cleanMountPath}/role`,
|
||||
method: "LIST",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const roleNames = roleListResponse.data.keys;
|
||||
|
||||
if (!roleNames || roleNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 3. Fetch details for each role with concurrency control
|
||||
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
|
||||
|
||||
const roleDetailsPromises = roleNames.map((roleName) =>
|
||||
limiter(async () => {
|
||||
const { data: roleResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthRole }>(
|
||||
connection,
|
||||
gatewayService,
|
||||
{
|
||||
url: `${instanceUrl}/v1/auth/${cleanMountPath}/role/${roleName}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
"X-Vault-Namespace": namespace
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Merge the role with the config
|
||||
return {
|
||||
...roleResponse.data,
|
||||
name: roleName,
|
||||
config: kubernetesConfig,
|
||||
mountPath: cleanMountPath
|
||||
} as THCVaultKubernetesAuthRoleWithConfig;
|
||||
})
|
||||
);
|
||||
|
||||
const roles = await Promise.all(roleDetailsPromises);
|
||||
|
||||
return roles;
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to list HC Vault Kubernetes auth roles");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error";
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list Kubernetes auth roles: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list Kubernetes auth roles from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,8 @@ export const hcVaultConnectionService = (
|
||||
|
||||
try {
|
||||
const mounts = await listHCVaultMounts(appConnection, gatewayService);
|
||||
return mounts;
|
||||
// Filter for KV version 2 mounts only and extract just the paths
|
||||
return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
||||
return [];
|
||||
|
||||
@@ -33,3 +33,65 @@ export type THCVaultMountResponse = {
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type THCVaultMount = {
|
||||
path: string;
|
||||
type: string;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type THCVaultAuthMountResponse = {
|
||||
data: {
|
||||
[key: string]: {
|
||||
type: string;
|
||||
description: string;
|
||||
accessor: string;
|
||||
config: {
|
||||
default_lease_ttl: number;
|
||||
max_lease_ttl: number;
|
||||
force_no_cache: boolean;
|
||||
};
|
||||
local: boolean;
|
||||
seal_wrap: boolean;
|
||||
external_entropy_access: boolean;
|
||||
options: Record<string, string> | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type THCVaultAuthMount = {
|
||||
path: string;
|
||||
type: string;
|
||||
description: string;
|
||||
accessor: string;
|
||||
};
|
||||
|
||||
export type THCVaultKubernetesAuthConfig = {
|
||||
kubernetes_host: string;
|
||||
kubernetes_ca_cert?: string;
|
||||
issuer?: string;
|
||||
disable_iss_validation?: boolean;
|
||||
disable_local_ca_jwt?: boolean;
|
||||
};
|
||||
|
||||
export type THCVaultKubernetesAuthRole = {
|
||||
name: string;
|
||||
bound_service_account_names: string[];
|
||||
bound_service_account_namespaces: string[];
|
||||
token_ttl?: number;
|
||||
token_max_ttl?: number;
|
||||
token_policies?: string[];
|
||||
token_bound_cidrs?: string[];
|
||||
token_explicit_max_ttl?: number;
|
||||
token_no_default_policy?: boolean;
|
||||
token_num_uses?: number;
|
||||
token_period?: number;
|
||||
token_type?: string;
|
||||
audience?: string;
|
||||
alias_name_source?: string;
|
||||
};
|
||||
|
||||
export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & {
|
||||
config: THCVaultKubernetesAuthConfig;
|
||||
mountPath: string;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import { OrgMembershipRole } from "@app/db/schemas";
|
||||
import {
|
||||
AuditLogInfo,
|
||||
EventType,
|
||||
SecretApprovalEvent,
|
||||
TAuditLogServiceFactory
|
||||
} from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection/app-connection-enums";
|
||||
import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns";
|
||||
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
|
||||
import {
|
||||
getHCVaultAuthMounts,
|
||||
getHCVaultKubernetesAuthRoles,
|
||||
getHCVaultSecretsForPath,
|
||||
HCVaultAuthType,
|
||||
listHCVaultMounts,
|
||||
listHCVaultPolicies,
|
||||
listHCVaultSecretPaths,
|
||||
THCVaultConnection
|
||||
} from "../app-connection/hc-vault";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TSecretServiceFactory } from "../secret/secret-service";
|
||||
import { SecretProtectionType } from "../secret/secret-types";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import {
|
||||
decryptEnvKeyDataFn,
|
||||
@@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
ExternalPlatforms,
|
||||
TCreateVaultExternalMigrationDTO,
|
||||
TDeleteVaultExternalMigrationDTO,
|
||||
THasCustomVaultMigrationDTO,
|
||||
TImportEnvKeyDataDTO,
|
||||
TImportVaultDataDTO
|
||||
TImportVaultDataDTO,
|
||||
TUpdateVaultExternalMigrationDTO,
|
||||
VaultImportStatus
|
||||
} from "./external-migration-types";
|
||||
import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal";
|
||||
|
||||
type TExternalMigrationServiceFactoryDep = {
|
||||
permissionService: TPermissionServiceFactory;
|
||||
secretService: TSecretServiceFactory;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
externalMigrationQueue: TExternalMigrationQueueFactory;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||
vaultExternalMigrationConfigDAL: Pick<
|
||||
TVaultExternalMigrationConfigDALFactory,
|
||||
"create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById"
|
||||
>;
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
||||
@@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({
|
||||
permissionService,
|
||||
externalMigrationQueue,
|
||||
userDAL,
|
||||
gatewayService
|
||||
gatewayService,
|
||||
secretService,
|
||||
auditLogService,
|
||||
appConnectionService,
|
||||
vaultExternalMigrationConfigDAL,
|
||||
kmsService
|
||||
}: TExternalMigrationServiceFactoryDep) => {
|
||||
const importEnvKeyData = async ({
|
||||
decryptionKey,
|
||||
@@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({
|
||||
return actorOrgId in vaultMigrationTransformMappings;
|
||||
};
|
||||
|
||||
const validateVaultExternalMigrationConnection = async ({
|
||||
connection,
|
||||
namespace
|
||||
}: {
|
||||
connection: THCVaultConnection;
|
||||
namespace: string;
|
||||
}) => {
|
||||
// Allow root namespace access when no namespace is configured on the connection
|
||||
const isRootAccess = namespace === "root" || namespace === "/";
|
||||
const hasNoNamespace = connection.credentials.namespace === undefined;
|
||||
|
||||
if (hasNoNamespace && isRootAccess) {
|
||||
// Skip validation for root access with no configured namespace
|
||||
} else if (connection.credentials.namespace !== namespace) {
|
||||
throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" });
|
||||
}
|
||||
|
||||
try {
|
||||
await listHCVaultPolicies(namespace, connection, gatewayService);
|
||||
await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService);
|
||||
|
||||
const mounts = await listHCVaultMounts(connection, gatewayService);
|
||||
const sampleKvMount = mounts.find((mount) => mount.type === "kv");
|
||||
if (sampleKvMount) {
|
||||
await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" });
|
||||
}
|
||||
|
||||
const connection = await appConnectionService.connectAppConnectionById<THCVaultConnection>(
|
||||
AppConnection.HCVault,
|
||||
connectionId,
|
||||
actor
|
||||
);
|
||||
|
||||
await validateVaultExternalMigrationConnection({
|
||||
connection,
|
||||
namespace
|
||||
});
|
||||
|
||||
try {
|
||||
const config = await vaultExternalMigrationConfigDAL.create({
|
||||
namespace,
|
||||
connectionId,
|
||||
orgId: actor.orgId
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DatabaseError &&
|
||||
(error.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: `Vault external migration already exists for this namespace`
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateVaultExternalMigration = async ({
|
||||
id,
|
||||
namespace,
|
||||
connectionId,
|
||||
actor
|
||||
}: TUpdateVaultExternalMigrationDTO) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can update vault external migration" });
|
||||
}
|
||||
|
||||
if (connectionId) {
|
||||
const connection = await appConnectionService.connectAppConnectionById<THCVaultConnection>(
|
||||
AppConnection.HCVault,
|
||||
connectionId,
|
||||
actor
|
||||
);
|
||||
|
||||
await validateVaultExternalMigrationConnection({
|
||||
connection,
|
||||
namespace
|
||||
});
|
||||
}
|
||||
|
||||
const config = await vaultExternalMigrationConfigDAL.updateById(id, {
|
||||
namespace,
|
||||
connectionId
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" });
|
||||
}
|
||||
|
||||
const configs = await vaultExternalMigrationConfigDAL.find({
|
||||
orgId: actor.orgId
|
||||
});
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" });
|
||||
}
|
||||
|
||||
// Get all configured namespaces for this org
|
||||
const vaultConfigs = await vaultExternalMigrationConfigDAL.find({
|
||||
orgId: actor.orgId
|
||||
});
|
||||
|
||||
// Return the configured namespaces as an array of objects with id and name
|
||||
// where both id and name are the namespace path
|
||||
const namespaces = vaultConfigs.map((config) => ({
|
||||
id: config.namespace,
|
||||
name: config.namespace
|
||||
}));
|
||||
|
||||
return namespaces;
|
||||
};
|
||||
|
||||
const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault policies" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const policies = await listHCVaultPolicies(namespace, connection, gatewayService);
|
||||
return policies;
|
||||
};
|
||||
|
||||
const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
|
||||
return mounts;
|
||||
};
|
||||
|
||||
const getVaultSecretPaths = async ({
|
||||
actor,
|
||||
namespace,
|
||||
mountPath
|
||||
}: {
|
||||
actor: OrgServiceActor;
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
|
||||
|
||||
return secretPaths;
|
||||
};
|
||||
|
||||
const importVaultSecrets = async ({
|
||||
actor,
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
vaultNamespace,
|
||||
vaultSecretPath,
|
||||
auditLogInfo
|
||||
}: {
|
||||
actor: OrgServiceActor;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
vaultNamespace: string;
|
||||
vaultSecretPath: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace: vaultNamespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService);
|
||||
|
||||
try {
|
||||
const secretOperation = await secretService.createManySecretsRaw({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue
|
||||
}))
|
||||
});
|
||||
|
||||
if (secretOperation.type === SecretProtectionType.Approval) {
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
...auditLogInfo,
|
||||
event: {
|
||||
type: EventType.SECRET_APPROVAL_REQUEST,
|
||||
metadata: {
|
||||
committedBy: secretOperation.approval.committerUserId,
|
||||
secretApprovalRequestId: secretOperation.approval.id,
|
||||
secretApprovalRequestSlug: secretOperation.approval.slug,
|
||||
secretPath,
|
||||
environment,
|
||||
secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({
|
||||
secretKey
|
||||
})),
|
||||
eventType: SecretApprovalEvent.CreateMany
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { status: VaultImportStatus.ApprovalRequired };
|
||||
}
|
||||
|
||||
return { status: VaultImportStatus.Imported };
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" });
|
||||
}
|
||||
|
||||
const config = await vaultExternalMigrationConfigDAL.findById(id);
|
||||
|
||||
if (!config) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found" });
|
||||
}
|
||||
|
||||
if (config.orgId !== actor.orgId) {
|
||||
throw new ForbiddenRequestError({ message: "Config does not belong to this organization" });
|
||||
}
|
||||
|
||||
const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id);
|
||||
|
||||
return deletedConfig;
|
||||
};
|
||||
|
||||
const getVaultAuthMounts = async ({
|
||||
actor,
|
||||
namespace,
|
||||
authType
|
||||
}: {
|
||||
actor: OrgServiceActor;
|
||||
namespace: string;
|
||||
authType?: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
|
||||
|
||||
return authMounts;
|
||||
};
|
||||
|
||||
const getVaultKubernetesAuthRoles = async ({
|
||||
actor,
|
||||
namespace,
|
||||
mountPath
|
||||
}: {
|
||||
actor: OrgServiceActor;
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
// Get roles for the specified mount path only
|
||||
const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
|
||||
|
||||
return roles;
|
||||
};
|
||||
|
||||
return {
|
||||
importEnvKeyData,
|
||||
importVaultData,
|
||||
hasCustomVaultMigration
|
||||
hasCustomVaultMigration,
|
||||
createVaultExternalMigration,
|
||||
getVaultExternalMigrationConfigs,
|
||||
updateVaultExternalMigration,
|
||||
deleteVaultExternalMigration,
|
||||
getVaultNamespaces,
|
||||
getVaultPolicies,
|
||||
getVaultMounts,
|
||||
getVaultAuthMounts,
|
||||
getVaultSecretPaths,
|
||||
importVaultSecrets,
|
||||
getVaultKubernetesAuthRoles
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOrgPermission } from "@app/lib/types";
|
||||
import { OrgServiceActor, TOrgPermission } from "@app/lib/types";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
@@ -121,3 +121,26 @@ export enum ExternalMigrationProviders {
|
||||
Vault = "vault",
|
||||
EnvKey = "env-key"
|
||||
}
|
||||
|
||||
export enum VaultImportStatus {
|
||||
Imported = "imported",
|
||||
ApprovalRequired = "approval-required"
|
||||
}
|
||||
|
||||
export type TCreateVaultExternalMigrationDTO = {
|
||||
namespace: string;
|
||||
connectionId: string;
|
||||
actor: OrgServiceActor;
|
||||
};
|
||||
|
||||
export type TUpdateVaultExternalMigrationDTO = {
|
||||
id: string;
|
||||
namespace: string;
|
||||
connectionId: string | null;
|
||||
actor: OrgServiceActor;
|
||||
};
|
||||
|
||||
export type TDeleteVaultExternalMigrationDTO = {
|
||||
id: string;
|
||||
actor: OrgServiceActor;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TVaultExternalMigrationConfigDALFactory = ReturnType<typeof vaultExternalMigrationConfigDALFactory>;
|
||||
|
||||
export const vaultExternalMigrationConfigDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.VaultExternalMigrationConfig);
|
||||
|
||||
const findOne = async (filter: { orgId: string; namespace: string }, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db?.replicaNode?.() || db)(TableName.VaultExternalMigrationConfig)
|
||||
.leftJoin(
|
||||
TableName.AppConnection,
|
||||
`${TableName.AppConnection}.id`,
|
||||
`${TableName.VaultExternalMigrationConfig}.connectionId`
|
||||
)
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
.where(buildFindFilter(prependTableNameToFindFilter(TableName.VaultExternalMigrationConfig, filter)))
|
||||
.select(selectAllTableCols(TableName.VaultExternalMigrationConfig))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.AppConnection).as("appConnectionId"),
|
||||
db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"),
|
||||
db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"),
|
||||
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"),
|
||||
db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"),
|
||||
db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"),
|
||||
db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"),
|
||||
db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"),
|
||||
db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"),
|
||||
db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"),
|
||||
db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt")
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
return {
|
||||
...result,
|
||||
connection: result.appConnectionId
|
||||
? {
|
||||
id: result.appConnectionId,
|
||||
name: result.appConnectionName,
|
||||
app: result.appConnectionApp,
|
||||
encryptedCredentials: result.appConnectionEncryptedCredentials,
|
||||
orgId: result.appConnectionOrgId,
|
||||
method: result.appConnectionMethod,
|
||||
description: result.appConnectionDescription,
|
||||
version: result.appConnectionVersion,
|
||||
gatewayId: result.appConnectionGatewayId,
|
||||
projectId: result.appConnectionProjectId,
|
||||
createdAt: result.appConnectionCreatedAt,
|
||||
updatedAt: result.appConnectionUpdatedAt
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find one" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...orm, findOne };
|
||||
};
|
||||
@@ -1,16 +1,22 @@
|
||||
---
|
||||
title: "External Migrations"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical."
|
||||
description: "Learn how to migrate resources from third-party secrets management platforms to Infisical."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform.
|
||||
Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform.
|
||||
|
||||
Infisical offers two types of migration approaches:
|
||||
|
||||
- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed.
|
||||
|
||||
- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- [EnvKey](./envkey)
|
||||
- [Vault](./vault)
|
||||
|
||||
We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
|
||||
We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
|
||||
|
||||
@@ -1,40 +1,239 @@
|
||||
---
|
||||
title: "Migrating from Vault to Infisical"
|
||||
sidebarTitle: "Vault"
|
||||
description: "Learn how to migrate secrets from Vault to Infisical."
|
||||
description: "Learn how to migrate resources from Vault to Infisical."
|
||||
---
|
||||
|
||||
## Migrating from Vault
|
||||
Infisical provides two approaches for migrating from HashiCorp Vault.
|
||||
|
||||
Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance.
|
||||
### Which approach should I use?
|
||||
|
||||
Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
|
||||
**Choose In-Platform Migration Tooling if you want to:**
|
||||
|
||||
- Migrate specific secrets, not everything at once
|
||||
- Import secrets into existing Infisical projects
|
||||
- Translate Vault policies to Infisical access controls
|
||||
- Import Kubernetes authentication configurations
|
||||
- Have more control over the migration process
|
||||
|
||||
### Prerequisites
|
||||
**Choose Bulk Data Import if you want to:**
|
||||
|
||||
- A Vault instance with the KV secret engine enabled.
|
||||
- An access token to your Vault instance.
|
||||
|
||||
|
||||
### Project Mapping
|
||||
|
||||
When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects.
|
||||
|
||||
There are two options for project mapping:
|
||||
|
||||
- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines.
|
||||
- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine.
|
||||
- Migrate all secrets from Vault in one go
|
||||
- Automatically create new Infisical projects from your Vault structure
|
||||
- Perform a one-time migration when moving entirely from Vault to Infisical
|
||||
|
||||
## In-Platform Migration Tooling
|
||||
|
||||
This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical.
|
||||
|
||||
### Step 1: Set Up Your Vault Connection
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Vault policy">
|
||||
In order to migrate from Vault, you'll need to create a Vault policy that allows Infisical to read the secrets and metadata from the KV v2 secrets engines within your Vault instance.
|
||||
In your Vault instance, create a policy that allows Infisical to read your secrets, policies, and authentication configurations. This policy grants read-only access and doesn't allow Infisical to modify anything in Vault.
|
||||
|
||||
<Accordion title="View the complete policy">
|
||||
```hcl
|
||||
# System endpoints - for listing namespaces, policies, mounts, and auth methods
|
||||
path "sys/namespaces" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
```python
|
||||
path "sys/policy" {
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
|
||||
path "sys/policy/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "sys/mounts" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "sys/auth" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# KV v2 secrets - for listing and reading secrets
|
||||
# Replace '+' with your actual KV v2 mount paths (e.g., "secret", "kv")
|
||||
path "+/metadata/*" {
|
||||
capabilities = ["list", "read"]
|
||||
}
|
||||
|
||||
path "+/data/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# KV v1 secrets - for listing and reading secrets
|
||||
# Replace '+' with your actual KV v1 mount paths (e.g., "secret", "kv-v1")
|
||||
# WARNING: This is broad - ideally specify exact mount names
|
||||
path "+/*" {
|
||||
capabilities = ["list", "read"]
|
||||
}
|
||||
|
||||
# Kubernetes auth - for reading auth configuration and roles
|
||||
path "auth/+/config" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "auth/+/role" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
path "auth/+/role/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
Save this policy in Vault with the name `infisical-in-platform-migration`.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create an App Connection in Infisical">
|
||||
In Infisical, navigate to **Organization Settings > App Connections** and create a new HashiCorp Vault connection.
|
||||
|
||||
Follow the [HashiCorp Vault App Connection documentation](/integrations/app-connections/hashicorp-vault) for detailed setup instructions. When configuring authentication (Token or AppRole), make sure it uses the `infisical-in-platform-migration` policy you created.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add Vault Namespaces for Migration">
|
||||
Navigate to **Organization Settings > External Migrations** in Infisical.
|
||||
|
||||
Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**.
|
||||
|
||||

|
||||
|
||||
Configure your namespace:
|
||||
|
||||

|
||||
|
||||
- **Namespace**: Enter your Vault namespace path (e.g., `admin/namespace1`). If you intend to use the root namespace, set the namespace value to "root".
|
||||
- **Connection**: Select the App Connection you created in the previous step.
|
||||
|
||||
<Note>
|
||||
You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Step 2: Import Your Resources
|
||||
|
||||
Once your Vault connection is configured, you'll see import options throughout Infisical wherever relevant. Here's what you can import:
|
||||
|
||||
#### Import Secrets into a Project
|
||||
|
||||
You can import secrets from Vault directly into a specific environment and secret path:
|
||||
|
||||
1. Navigate to your project and select a specific environment (e.g., Development, Production)
|
||||
2. In the secrets view, click the dropdown icon (caret) next to the **"+ Add Secret"** button
|
||||
3. Select **"Add from HashiCorp Vault"**
|
||||
|
||||

|
||||
|
||||
4. Choose your Vault namespace and the secret path you want to import
|
||||
5. Click **"Import Secrets"**
|
||||
|
||||
The secrets will be imported into your current environment and folder path.
|
||||
|
||||
#### Import Kubernetes Authentication Configurations
|
||||
|
||||
When setting up Kubernetes authentication for a machine identity, you can import the configuration from Vault:
|
||||
|
||||
1. Navigate to **Access Control > Machine Identities** and select an identity
|
||||
2. Click **"Add Authentication Method"** and choose **Kubernetes Auth**
|
||||
3. In the configuration modal, click **"Load from Vault"**
|
||||
|
||||

|
||||
|
||||
4. Select your Vault namespace and the Kubernetes role
|
||||
5. Click **"Load"**
|
||||
|
||||

|
||||
|
||||
The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration.
|
||||
|
||||
<Note>
|
||||
Sensitive values like service account JWTs cannot be retrieved from Vault and
|
||||
must be manually provided in the form after importing the configuration.
|
||||
</Note>
|
||||
|
||||
#### Import and Translate Access Control Policies
|
||||
|
||||
When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions.
|
||||
|
||||
<Note>
|
||||
Policy translation is best-effort and provides a starting point based on your
|
||||
Vault configuration. The translated permissions should be reviewed and
|
||||
adjusted as needed since Vault and Infisical have different access control
|
||||
models. Infisical will analyze path patterns and capabilities to suggest
|
||||
equivalent permissions.
|
||||
</Note>
|
||||
|
||||
**To import and translate a policy:**
|
||||
|
||||
1. Navigate to your project, then go to **Access Control > Roles** and create or edit a role
|
||||
2. In the policy configuration, click **"Add from HashiCorp Vault"**
|
||||
|
||||

|
||||
|
||||
3. Select your Vault namespace
|
||||
4. Either choose an existing policy from the dropdown or paste your own HCL policy
|
||||
|
||||

|
||||
|
||||
5. Review the automatically translated Infisical permissions
|
||||
6. Make any adjustments and save
|
||||
|
||||
<Tip>
|
||||
**How policy translation works:**
|
||||
|
||||
- Vault path patterns are analyzed to identify KV secret engines and environments
|
||||
- Vault capabilities (`read`,`list`, `create`, etc.) are mapped to Infisical permissions
|
||||
- Wildcards in paths are converted to glob patterns
|
||||
- Secret paths are preserved for granular access control
|
||||
|
||||
Always review the translated permissions carefully, as Vault's capability-based model may not map 1:1 with Infisical's permission structure.
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Bulk Data Import
|
||||
|
||||
This migration approach imports all secrets from your Vault instance in one operation and automatically creates new Infisical projects based on your Vault structure.
|
||||
|
||||
### Understanding Project Mapping
|
||||
|
||||
Before starting the bulk import, you need to decide how your Vault structure will map to Infisical projects:
|
||||
|
||||
<Accordion title="Namespace Mapping (One Project Per Namespace)">
|
||||
Each Vault namespace becomes a single Infisical project, with each KV secret engine becoming an environment within that project.
|
||||
|
||||
**Example:** If you have a namespace with 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`):
|
||||
|
||||
- Creates: 1 Infisical project
|
||||
- Environments: 3 (`dev-secrets`, `staging-secrets`, `prod-secrets`)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Key Vault Mapping (One Project Per KV Engine)">
|
||||
Each KV secret engine becomes its own Infisical project with a single `Production` environment.
|
||||
|
||||
**Example:** If you have 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`):
|
||||
|
||||
- Creates: 3 Infisical projects (`dev-secrets`, `staging-secrets`, `prod-secrets`)
|
||||
- Each project has: 1 environment (`Production`)
|
||||
</Accordion>
|
||||
|
||||
### How to Perform a Bulk Import
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Vault policy for bulk import">
|
||||
In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access.
|
||||
|
||||
<Accordion title="View the bulk import policy">
|
||||
```hcl
|
||||
# Allow listing secret engines/mounts
|
||||
path "sys/mounts" {
|
||||
capabilities = ["read", "list"]
|
||||
@@ -63,65 +262,54 @@ There are two options for project mapping:
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
Save this policy with the name `infisical-migration`.
|
||||
Save this policy in Vault with the name `infisical-bulk-migration`.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Generate an access token">
|
||||
You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step.
|
||||
Use the Vault CLI to generate an access token:
|
||||
|
||||
```bash
|
||||
vault token create --policy="infisical-migration"
|
||||
vault token create --policy="infisical-bulk-migration"
|
||||
```
|
||||
|
||||
After generating the token, you should see the following output:
|
||||
Copy the `token` value from the output - you'll need it in the next step.
|
||||
|
||||
```t
|
||||
$ vault token create --policy="infisical-migration"
|
||||
|
||||
Key Value
|
||||
--- -----
|
||||
token <your-access-token>
|
||||
token_accessor p6kJDiBSzYYdabJUIpGCsCBm
|
||||
token_duration 768h
|
||||
token_renewable true
|
||||
token_policies ["default" "infisical-migration"]
|
||||
identity_policies []
|
||||
policies ["default" "infisical-migration"]
|
||||
```
|
||||
|
||||
Copy the `token` field and save it for later, as you'll need this when configuring the migration to Infisical.
|
||||
</Step>
|
||||
|
||||
<Step title="Navigate to Infisical external migrations">
|
||||
Open the Infisical dashboard and go to Organization Settings > External Migrations.
|
||||
<Step title="Start the import in Infisical">
|
||||
In Infisical, navigate to **Organization Settings > External Migrations**.
|
||||
|
||||

|
||||
|
||||
Under the "Bulk Data Import" section, click **"+ Import"**.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Select the Vault platform">
|
||||
Select the Vault platform and click on Next.
|
||||
<Step title="Select Vault as the source">
|
||||
Select **HashiCorp Vault** as the migration source and click **Next**.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Configure the Vault migration">
|
||||
Enter the Vault access token that you generated in the previous step and click Import data.
|
||||
<Step title="Configure and start the migration">
|
||||
Fill in your Vault connection details:
|
||||
|
||||

|
||||
|
||||
- `Vault URL`: The URL of your Vault instance.
|
||||
- `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance.
|
||||
- `Vault Access Token`: The access token that you generated in the previous step.
|
||||
- **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`)
|
||||
- **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces
|
||||
- **Vault Access Token**: The token you generated in step 2
|
||||
- **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping))
|
||||
|
||||
- `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section.
|
||||
Click **"Import Data"** to start the migration.
|
||||
|
||||
Click on Import data to start the migration.
|
||||
<Note>
|
||||
The import runs in the background and may take several minutes. You'll receive an email when it completes.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
It may take several minutes to complete the migration. You will receive an email when the migration is complete, or if there were any errors during the migration process.
|
||||
</Note>
|
||||
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 531 KiB |
@@ -1,2 +1,3 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
|
||||
import { projectKeys } from "../projects";
|
||||
import { externalMigrationQueryKeys } from "./queries";
|
||||
import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types";
|
||||
|
||||
export const useImportEnvKey = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -65,3 +69,97 @@ export const useImportVault = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportVaultSecrets = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post<{ status: VaultImportStatus }>(
|
||||
"/api/v3/external-migration/vault/import-secrets",
|
||||
dto
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({
|
||||
projectId,
|
||||
environment,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateVaultExternalMigrationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TVaultExternalMigrationConfig,
|
||||
Error,
|
||||
{ connectionId: string; namespace: string }
|
||||
>({
|
||||
mutationFn: async ({ connectionId, namespace }) => {
|
||||
const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>(
|
||||
"/api/v3/external-migration/vault/configs",
|
||||
{
|
||||
connectionId,
|
||||
namespace
|
||||
}
|
||||
);
|
||||
return data.config;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: externalMigrationQueryKeys.vaultConfigs()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateVaultExternalMigrationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TVaultExternalMigrationConfig,
|
||||
Error,
|
||||
{ id: string; connectionId: string; namespace: string }
|
||||
>({
|
||||
mutationFn: async ({ id, connectionId, namespace }) => {
|
||||
const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>(
|
||||
`/api/v3/external-migration/vault/configs/${id}`,
|
||||
{
|
||||
connectionId,
|
||||
namespace
|
||||
}
|
||||
);
|
||||
return data.config;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: externalMigrationQueryKeys.vaultConfigs()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteVaultExternalMigrationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TVaultExternalMigrationConfig, Error, { id: string }>({
|
||||
mutationFn: async ({ id }) => {
|
||||
const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>(
|
||||
`/api/v3/external-migration/vault/configs/${id}`
|
||||
);
|
||||
return data.config;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: externalMigrationQueryKeys.vaultConfigs()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { ExternalMigrationProviders } from "./types";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
TVaultExternalMigrationConfig,
|
||||
VaultKubernetesAuthRole
|
||||
} from "./types";
|
||||
|
||||
const externalMigrationQueryKeys = {
|
||||
export const externalMigrationQueryKeys = {
|
||||
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
||||
"custom-migration-available",
|
||||
provider
|
||||
],
|
||||
vaultConfigs: () => ["vault-external-migration-configs"],
|
||||
vaultNamespaces: () => ["vault-namespaces"],
|
||||
vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
|
||||
vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
|
||||
vaultAuthMounts: (namespace?: string, authType?: string) => [
|
||||
"vault-auth-mounts",
|
||||
namespace,
|
||||
authType
|
||||
],
|
||||
vaultSecretPaths: (namespace?: string, mountPath?: string) => [
|
||||
"vault-secret-paths",
|
||||
namespace,
|
||||
mountPath
|
||||
],
|
||||
vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [
|
||||
"vault-kubernetes-auth-roles",
|
||||
namespace,
|
||||
mountPath
|
||||
]
|
||||
};
|
||||
|
||||
@@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultExternalMigrationConfigs = () => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultConfigs(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>(
|
||||
"/api/v3/external-migration/vault/configs"
|
||||
);
|
||||
return data.configs;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultNamespaces = () => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultNamespaces(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
namespaces: Array<{ id: string; name: string }>;
|
||||
}>("/api/v3/external-migration/vault/namespaces");
|
||||
return data.namespaces;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultPolicies = (enabled = true, namespace?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultPolicies(namespace),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
policies: Array<{ name: string; rules: string }>;
|
||||
}>("/api/v3/external-migration/vault/policies", {
|
||||
params: {
|
||||
namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.policies;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultMounts = (enabled = true, namespace?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultMounts(namespace),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
mounts: Array<{ path: string; type: string; version: string | null }>;
|
||||
}>("/api/v3/external-migration/vault/mounts", {
|
||||
params: {
|
||||
namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.mounts;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath),
|
||||
queryFn: async () => {
|
||||
if (!namespace || !mountPath) {
|
||||
throw new Error("Both namespace and mountPath are required");
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.get<{
|
||||
secretPaths: string[];
|
||||
}>("/api/v3/external-migration/vault/secret-paths", {
|
||||
params: {
|
||||
namespace,
|
||||
mountPath
|
||||
}
|
||||
});
|
||||
|
||||
return data.secretPaths;
|
||||
},
|
||||
enabled: enabled && !!namespace && !!mountPath
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
mounts: Array<{ path: string; type: string }>;
|
||||
}>("/api/v3/external-migration/vault/auth-mounts", {
|
||||
params: {
|
||||
namespace,
|
||||
...(authType && { authType })
|
||||
}
|
||||
});
|
||||
|
||||
return data.mounts;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultKubernetesAuthRoles = (
|
||||
enabled = true,
|
||||
namespace?: string,
|
||||
mountPath?: string
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath),
|
||||
queryFn: async () => {
|
||||
if (!namespace || !mountPath) {
|
||||
throw new Error("Both namespace and mountPath are required");
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.get<{
|
||||
roles: VaultKubernetesAuthRole[];
|
||||
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
|
||||
params: {
|
||||
namespace,
|
||||
mountPath
|
||||
}
|
||||
});
|
||||
|
||||
return data.roles;
|
||||
},
|
||||
enabled: enabled && !!namespace && !!mountPath
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,3 +2,50 @@ export enum ExternalMigrationProviders {
|
||||
Vault = "vault",
|
||||
EnvKey = "env-key"
|
||||
}
|
||||
|
||||
export enum VaultImportStatus {
|
||||
Imported = "imported",
|
||||
ApprovalRequired = "approval-required"
|
||||
}
|
||||
|
||||
export type TVaultExternalMigrationConfig = {
|
||||
id: string;
|
||||
orgId: string;
|
||||
namespace: string;
|
||||
connectionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TImportVaultSecretsDTO = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
vaultNamespace: string;
|
||||
vaultSecretPath: string;
|
||||
};
|
||||
|
||||
export type VaultKubernetesAuthRole = {
|
||||
name: string;
|
||||
bound_service_account_names: string[];
|
||||
bound_service_account_namespaces: string[];
|
||||
token_ttl?: number;
|
||||
token_max_ttl?: number;
|
||||
token_policies?: string[];
|
||||
token_bound_cidrs?: string[];
|
||||
token_explicit_max_ttl?: number;
|
||||
token_no_default_policy?: boolean;
|
||||
token_num_uses?: number;
|
||||
token_period?: number;
|
||||
token_type?: string;
|
||||
audience?: string;
|
||||
alias_name_source?: string;
|
||||
mountPath: string;
|
||||
config: {
|
||||
kubernetes_host: string;
|
||||
kubernetes_ca_cert?: string;
|
||||
issuer?: string;
|
||||
disable_iss_validation?: boolean;
|
||||
disable_local_ca_jwt?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -37,9 +37,12 @@ import {
|
||||
IdentityKubernetesAuthTokenReviewMode,
|
||||
IdentityTrustedIp
|
||||
} from "@app/hooks/api/identities/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries";
|
||||
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
|
||||
import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityFormTab } from "./types";
|
||||
import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -121,6 +124,12 @@ export const IdentityKubernetesAuthForm = ({
|
||||
enabled: isUpdate
|
||||
});
|
||||
|
||||
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
|
||||
"importFromVault"
|
||||
] as const);
|
||||
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -192,6 +201,99 @@ export const IdentityKubernetesAuthForm = ({
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleImportFromVault = (role: VaultKubernetesAuthRole) => {
|
||||
try {
|
||||
setValue("kubernetesHost", role.config.kubernetes_host, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
if (role.bound_service_account_names?.length > 0) {
|
||||
// In Vault, "*" means allow all; in Infisical, empty field means allow any
|
||||
const allowedNames = role.bound_service_account_names.includes("*")
|
||||
? ""
|
||||
: role.bound_service_account_names.join(", ");
|
||||
setValue("allowedNames", allowedNames, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.bound_service_account_namespaces?.length > 0) {
|
||||
// In Vault, "*" means allow all; in Infisical, empty field means allow any
|
||||
const allowedNamespaces = role.bound_service_account_namespaces.includes("*")
|
||||
? ""
|
||||
: role.bound_service_account_namespaces.join(", ");
|
||||
setValue("allowedNamespaces", allowedNamespaces, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_ttl !== undefined) {
|
||||
setValue("accessTokenTTL", String(role.token_ttl), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_max_ttl !== undefined) {
|
||||
setValue("accessTokenMaxTTL", String(role.token_max_ttl), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_num_uses !== undefined) {
|
||||
setValue("accessTokenNumUsesLimit", String(role.token_num_uses), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.audience) {
|
||||
setValue("allowedAudience", role.audience, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.config.kubernetes_ca_cert) {
|
||||
setValue("caCert", role.config.kubernetes_ca_cert, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
subscription?.ipAllowlisting &&
|
||||
role.token_bound_cidrs &&
|
||||
role.token_bound_cidrs.length > 0
|
||||
) {
|
||||
setValue(
|
||||
"accessTokenTrustedIps",
|
||||
role.token_bound_cidrs.map((cidr) => ({ ipAddress: cidr })),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: `Successfully prefilled values from Kubernetes auth role: ${role.name}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to import Kubernetes auth configuration"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({
|
||||
kubernetesHost,
|
||||
tokenReviewerJwt,
|
||||
@@ -301,6 +403,28 @@ export const IdentityKubernetesAuthForm = ({
|
||||
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={IdentityFormTab.Configuration}>
|
||||
{hasVaultConnection && !isUpdate && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-md border border-primary/30 bg-primary/10 p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
|
||||
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
onClick={() => handleImportPopUpToggle("importFromVault", true)}
|
||||
>
|
||||
Load from Vault
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-full flex-1">
|
||||
<OrgPermissionCan
|
||||
@@ -407,6 +531,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
placeholder="https://my-example-k8s-api-host.com"
|
||||
type="text"
|
||||
value={field.value || ""}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -425,7 +550,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
errorText={error?.message}
|
||||
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
|
||||
>
|
||||
<Input {...field} placeholder="" type="password" />
|
||||
<Input {...field} placeholder="" type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -441,7 +566,12 @@ export const IdentityKubernetesAuthForm = ({
|
||||
errorText={error?.message}
|
||||
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
|
||||
>
|
||||
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="namespaceA, namespaceB"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -456,7 +586,11 @@ export const IdentityKubernetesAuthForm = ({
|
||||
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account."
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="service-account-1-name, service-account-1-name" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="service-account-1-name, service-account-1-name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -628,6 +762,11 @@ export const IdentityKubernetesAuthForm = ({
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<VaultKubernetesAuthImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handleImportPopUpToggle("importFromVault", isOpen)}
|
||||
onImport={handleImportFromVault}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useGetVaultAuthMounts,
|
||||
useGetVaultKubernetesAuthRoles,
|
||||
useGetVaultNamespaces
|
||||
} from "@app/hooks/api/migration/queries";
|
||||
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onImport: (role: VaultKubernetesAuthRole) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
onImport: (role: VaultKubernetesAuthRole) => void;
|
||||
};
|
||||
|
||||
const Content = ({ onClose, onImport }: ContentProps) => {
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
|
||||
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
|
||||
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
|
||||
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
|
||||
const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts(
|
||||
shouldFetchMounts,
|
||||
selectedNamespace ?? undefined,
|
||||
"kubernetes"
|
||||
);
|
||||
const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles(
|
||||
shouldFetchRoles,
|
||||
selectedNamespace ?? undefined,
|
||||
selectedMountPath ?? undefined
|
||||
);
|
||||
|
||||
// Enable fetching mounts when namespace is selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchMounts(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
// Enable fetching roles when both namespace and mount path are selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace && selectedMountPath) {
|
||||
setShouldFetchRoles(true);
|
||||
} else {
|
||||
setShouldFetchRoles(false);
|
||||
}
|
||||
}, [selectedNamespace, selectedMountPath]);
|
||||
|
||||
const handleImportAndApply = () => {
|
||||
if (!selectedRole) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please select a Kubernetes role to load"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(selectedRole);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
className="mb-4"
|
||||
tooltipText="Select the Vault namespace containing the Kubernetes auth configuration."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={namespaces?.find((ns) => ns.name === selectedNamespace)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const namespace = value as { id: string; name: string };
|
||||
setSelectedNamespace(namespace.name);
|
||||
setSelectedMountPath(null);
|
||||
setSelectedRole(null);
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select the Vault namespace to fetch available auth mounts
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Auth Engine"
|
||||
className="mb-4"
|
||||
tooltipText="Select the Kubernetes auth engine to narrow down available roles."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={
|
||||
selectedMountPath
|
||||
? authMounts?.find((mount) => mount.path === selectedMountPath)
|
||||
: null
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const mount = value as { path: string; type: string };
|
||||
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
|
||||
setSelectedRole(null);
|
||||
} else {
|
||||
setSelectedMountPath(null);
|
||||
}
|
||||
}}
|
||||
options={authMounts || []}
|
||||
getOptionValue={(option) => option.path}
|
||||
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
|
||||
isDisabled={isLoadingMounts || !authMounts?.length}
|
||||
placeholder="Select auth engine..."
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Choose a Kubernetes auth engine to filter available roles
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Kubernetes Role" className="mb-6">
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={selectedRole}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
setSelectedRole(value as VaultKubernetesAuthRole);
|
||||
} else {
|
||||
setSelectedRole(null);
|
||||
}
|
||||
}}
|
||||
options={roles || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
|
||||
placeholder={
|
||||
!selectedMountPath
|
||||
? "Select an auth engine first..."
|
||||
: "Select a Kubernetes role to load..."
|
||||
}
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select the Kubernetes role to load configuration from
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button onClick={handleImportAndApply} isDisabled={!selectedRole || isLoadingRoles}>
|
||||
Load
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Load Kubernetes Auth from HashiCorp Vault"
|
||||
subTitle="Load Kubernetes authentication configuration from your Vault instance. The auth method and role settings will be automatically translated and prefilled in the form."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
|
||||
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
||||
import { VaultConnectionSection } from "./components/VaultConnectionSection";
|
||||
|
||||
export const ExternalMigrationsTab = () => {
|
||||
const { hasOrgRole } = useOrgPermission();
|
||||
@@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Import from external source</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* In-Platform Migration Tooling Section */}
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-medium text-mineshaft-100">In-Platform Migration Tooling</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-gray-400">
|
||||
Configure platform connections to enable migration features throughout Infisical, such
|
||||
as importing policies and resources directly within the UI.
|
||||
</p>
|
||||
</div>
|
||||
<VaultConnectionSection />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
||||
>
|
||||
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
Docs
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="text-xxs mb-[0.07rem] ml-1.5"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/* Bulk Data Import Section */}
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-medium text-mineshaft-100">Bulk Data Import</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-gray-400">
|
||||
Perform one-time bulk imports of data from external platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("selectImportPlatform");
|
||||
}}
|
||||
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-medium text-mineshaft-100">
|
||||
Import from external source
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
||||
>
|
||||
<div className="inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
Docs
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="text-xxs mb-[0.07rem] ml-1.5"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Import data from another platform to Infisical.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SelectImportFromPlatformModal
|
||||
isOpen={popUp.selectImportPlatform.isOpen}
|
||||
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("selectImportPlatform");
|
||||
}}
|
||||
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectImportFromPlatformModal
|
||||
isOpen={popUp.selectImportPlatform.isOpen}
|
||||
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from "react";
|
||||
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
|
||||
import {
|
||||
useDeleteVaultExternalMigrationConfig,
|
||||
useGetVaultExternalMigrationConfigs
|
||||
} from "@app/hooks/api/migration";
|
||||
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
|
||||
|
||||
import { VaultNamespaceConfigModal } from "./VaultNamespaceConfigModal";
|
||||
|
||||
export const VaultConnectionSection = () => {
|
||||
const [selectedConfig, setSelectedConfig] = useState<TVaultExternalMigrationConfig | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [configToDelete, setConfigToDelete] = useState<TVaultExternalMigrationConfig | null>(null);
|
||||
|
||||
const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs();
|
||||
const { data: appConnections = [] } = useListAppConnections();
|
||||
const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig();
|
||||
|
||||
const handleEdit = (config: TVaultExternalMigrationConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setSelectedConfig(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (config: TVaultExternalMigrationConfig) => {
|
||||
setConfigToDelete(config);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!configToDelete) return;
|
||||
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Namespace configuration deleted successfully"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setConfigToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete namespace config:", error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete namespace configuration"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionName = (connectionId: string | null) => {
|
||||
if (!connectionId) return "None";
|
||||
const connection = appConnections.find((conn) => conn.id === connectionId);
|
||||
return connection?.name || "Unknown";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault logo"
|
||||
className="h-10 w-10 rounded-md bg-bunker-500 p-2"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">HashiCorp Vault</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Enable in-platform migration tooling for policy imports, auth methods, and secret
|
||||
engine migrations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add Namespace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Namespace</Th>
|
||||
<Th>Connection</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoadingConfigs && (
|
||||
<TableSkeleton columns={3} innerKey="vault-configs-loading" rows={3} />
|
||||
)}
|
||||
{!isLoadingConfigs && configs.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={3}>
|
||||
<EmptyState title="No namespace configurations" icon={faPlus} className="py-8">
|
||||
<p className="mb-4 text-sm text-mineshaft-400">
|
||||
Add a namespace configuration to enable in-platform migration features.
|
||||
</p>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoadingConfigs &&
|
||||
configs.map((config) => (
|
||||
<Tr key={config.id} className="group h-10">
|
||||
<Td>{config.namespace}</Td>
|
||||
<Td>{getConnectionName(config.connectionId)}</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
size="xs"
|
||||
onClick={() => handleEdit(config)}
|
||||
leftIcon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="xs"
|
||||
onClick={() => handleDeleteClick(config)}
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<p className="mt-4 text-xs text-mineshaft-400">
|
||||
Configure namespace-specific connections to enable in-platform migration features. Manage
|
||||
connections in the{" "}
|
||||
<Link
|
||||
to="/organization/app-connections"
|
||||
className="text-primary underline hover:text-primary-300"
|
||||
>
|
||||
App Connections
|
||||
</Link>{" "}
|
||||
section.
|
||||
</p>
|
||||
|
||||
<VaultNamespaceConfigModal
|
||||
isOpen={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedConfig(null);
|
||||
}}
|
||||
editConfig={selectedConfig || undefined}
|
||||
/>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title={`Delete namespace configuration for "${configToDelete?.namespace}"?`}
|
||||
onChange={(open) => {
|
||||
setIsDeleteModalOpen(open);
|
||||
if (!open) setConfigToDelete(null);
|
||||
}}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleDeleteConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
|
||||
import {
|
||||
useCreateVaultExternalMigrationConfig,
|
||||
useUpdateVaultExternalMigrationConfig
|
||||
} from "@app/hooks/api/migration";
|
||||
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
|
||||
|
||||
const schema = z.object({
|
||||
namespace: z
|
||||
.string()
|
||||
.min(1, "Namespace is required. If you intend to use the root namespace, use root or /."),
|
||||
connectionId: z.string().min(1, "Connection is required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
editConfig?: TVaultExternalMigrationConfig;
|
||||
};
|
||||
|
||||
export const VaultNamespaceConfigModal = ({ isOpen, onOpenChange, editConfig }: Props) => {
|
||||
const isEdit = Boolean(editConfig);
|
||||
|
||||
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
|
||||
|
||||
const vaultConnections = useMemo(
|
||||
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
|
||||
[appConnections]
|
||||
);
|
||||
|
||||
const { mutateAsync: createConfig, isPending: isCreating } =
|
||||
useCreateVaultExternalMigrationConfig();
|
||||
const { mutateAsync: updateConfig, isPending: isUpdating } =
|
||||
useUpdateVaultExternalMigrationConfig();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
namespace: "",
|
||||
connectionId: ""
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form when editConfig changes or modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset({
|
||||
namespace: editConfig?.namespace || "",
|
||||
connectionId: editConfig?.connectionId || ""
|
||||
});
|
||||
}
|
||||
}, [isOpen, editConfig, reset]);
|
||||
|
||||
const onFormSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (isEdit && editConfig) {
|
||||
await updateConfig({
|
||||
id: editConfig.id,
|
||||
namespace: data.namespace,
|
||||
connectionId: data.connectionId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Namespace configuration updated successfully"
|
||||
});
|
||||
} else {
|
||||
await createConfig({
|
||||
namespace: data.namespace,
|
||||
connectionId: data.connectionId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Namespace configuration created successfully"
|
||||
});
|
||||
}
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save namespace config:", error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: `Failed to ${isEdit ? "update" : "create"} namespace configuration`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={handleClose}>
|
||||
<ModalContent
|
||||
title={isEdit ? "Edit Namespace Configuration" : "Add Namespace Configuration"}
|
||||
subTitle={`Configure a HashiCorp Vault namespace ${isEdit ? "configuration" : "for migration tooling"}`}
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="namespace"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
isError={Boolean(errors.namespace)}
|
||||
errorText={errors.namespace?.message}
|
||||
className="mb-4"
|
||||
>
|
||||
<Input {...field} placeholder="e.g., admin, dev, prod" autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="connectionId"
|
||||
render={({ field }) => {
|
||||
const selectedConnection = vaultConnections.find((conn) => conn.id === field.value);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
label="Vault Connection"
|
||||
isError={Boolean(errors.connectionId)}
|
||||
errorText={errors.connectionId?.message}
|
||||
tooltipText="Select a HashiCorp Vault app connection for this namespace"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={selectedConnection || null}
|
||||
onChange={(newValue) => {
|
||||
const singleValue = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||
if (singleValue && "id" in singleValue) {
|
||||
field.onChange(singleValue.id);
|
||||
} else {
|
||||
field.onChange("");
|
||||
}
|
||||
}}
|
||||
isLoading={isLoadingConnections}
|
||||
options={vaultConnections}
|
||||
placeholder="Select connection..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting || isCreating || isUpdating}
|
||||
isDisabled={isSubmitting || isCreating || isUpdating}
|
||||
>
|
||||
{isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
IconButton
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useOrgPermission } from "@app/context";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
|
||||
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
|
||||
import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal";
|
||||
|
||||
type Props = {
|
||||
isDisabled?: boolean;
|
||||
@@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addPolicy",
|
||||
"addPolicyOptions",
|
||||
"applyTemplate"
|
||||
"applyTemplate",
|
||||
"importFromVault"
|
||||
] as const);
|
||||
|
||||
const { hasOrgRole } = useOrgPermission();
|
||||
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
|
||||
const isVaultImportDisabled = isDisabled || !isOrgAdmin;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
@@ -64,6 +76,35 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
||||
>
|
||||
Add From Template
|
||||
</Button>
|
||||
{hasVaultConnection && (
|
||||
<Tooltip
|
||||
content={
|
||||
!isOrgAdmin
|
||||
? "Only organization admins can import policies from HashiCorp Vault"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
leftIcon={
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("importFromVault");
|
||||
handlePopUpClose("addPolicyOptions");
|
||||
}}
|
||||
isDisabled={isVaultImportDisabled}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Add from HashiCorp Vault
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
||||
isOpen={popUp.applyTemplate.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
|
||||
/>
|
||||
<VaultPolicyImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import {
|
||||
useGetVaultMounts,
|
||||
useGetVaultNamespaces,
|
||||
useGetVaultPolicies
|
||||
} from "@app/hooks/api/migration/queries";
|
||||
|
||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
||||
import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const rootForm = useFormContext<TFormSchema>();
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<string | null>(null);
|
||||
const [hclPolicy, setHclPolicy] = useState<string>("");
|
||||
const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false);
|
||||
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
|
||||
const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies(
|
||||
shouldFetchPolicies,
|
||||
selectedNamespace ?? undefined
|
||||
);
|
||||
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
|
||||
shouldFetchMounts,
|
||||
selectedNamespace ?? undefined
|
||||
);
|
||||
|
||||
// Enable fetching policies and mounts when namespace is selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchPolicies(true);
|
||||
setShouldFetchMounts(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
// Auto-populate HCL when a policy is selected
|
||||
useEffect(() => {
|
||||
if (selectedPolicy && policies) {
|
||||
const policy = policies.find((p) => p.name === selectedPolicy);
|
||||
if (policy) {
|
||||
setHclPolicy(policy.rules);
|
||||
}
|
||||
}
|
||||
}, [selectedPolicy, policies]);
|
||||
|
||||
const handleTranslateAndApply = () => {
|
||||
if (!hclPolicy.trim()) {
|
||||
createNotification({ type: "error", text: "Please provide a Vault HCL policy" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounts || mounts.length === 0) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedPermissions = parseVaultPolicyToInfisical(hclPolicy, mounts);
|
||||
|
||||
if (!parsedPermissions || Object.keys(parsedPermissions).length === 0) {
|
||||
createNotification({
|
||||
type: "warning",
|
||||
text: "No translatable permissions found in the policy. Ensure the policy contains KV secret paths (e.g., secret/data/*, secret/metadata/*)."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the parsed permissions to the form
|
||||
(Object.keys(parsedPermissions) as ProjectPermissionSub[]).forEach((subjectKey) => {
|
||||
const value = parsedPermissions[subjectKey];
|
||||
if (!value) return;
|
||||
|
||||
const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as unknown[];
|
||||
|
||||
if (Array.isArray(existingValue) && existingValue.length > 0) {
|
||||
// Merge with existing permissions
|
||||
rootForm.setValue(`permissions.${subjectKey}`, [...existingValue, ...value] as never, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
} else {
|
||||
rootForm.setValue(`permissions.${subjectKey}`, value as never, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "Vault policy translated and prefilled"
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Translation error:", err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to translate policy. Please check the HCL format."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<strong>How Policy Translation Works</strong>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed">
|
||||
<p>
|
||||
Policies are translated by identifying KV secret engine mounts and parsing path
|
||||
structures to extract environments and secret paths.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Key assumptions:</strong> The first path segment after the mount is treated
|
||||
as the environment (e.g., <code className="text-xs">secret/data/prod/app</code> →
|
||||
env: <code className="text-xs">prod</code>, path:{" "}
|
||||
<code className="text-xs">/app</code>). Vault capabilities and wildcards are
|
||||
automatically mapped to equivalent Infisical permissions and glob patterns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
className="mb-4"
|
||||
tooltipText="Required to fetch mount information. Policies will be translated using your Vault's KV secret engine mounts to extract environments and secret paths."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={namespaces?.find((ns) => ns.id === selectedNamespace)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const namespace = value as { id: string; name: string };
|
||||
setSelectedNamespace(namespace.name);
|
||||
setSelectedPolicy(null);
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select the Vault namespace to fetch policies and mount information
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Select Vault Policy (Optional)" className="mb-4">
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={selectedPolicy ? policies?.find((p) => p.name === selectedPolicy) : null}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const policy = value as { name: string; rules: string };
|
||||
setSelectedPolicy(policy.name);
|
||||
} else {
|
||||
setSelectedPolicy(null);
|
||||
}
|
||||
}}
|
||||
options={policies || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isDisabled={isLoadingPolicies}
|
||||
placeholder="Choose a policy to import..."
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select a policy to auto-populate the HCL editor below, or skip to paste your own
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Vault HCL Policy" className="mb-6">
|
||||
<>
|
||||
<TextArea
|
||||
value={hclPolicy}
|
||||
onChange={(e) => setHclPolicy(e.target.value)}
|
||||
placeholder={`path "secret/data/prod/app/*" {
|
||||
capabilities = ["create", "read", "update", "delete"]
|
||||
}
|
||||
|
||||
path "secret/metadata/prod/*" {
|
||||
capabilities = ["list"]
|
||||
}`}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Paste your HCL policy here or select one from the dropdown above. The translator will
|
||||
extract environments and paths automatically.
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button
|
||||
onClick={handleTranslateAndApply}
|
||||
isDisabled={!hclPolicy.trim() || isLoadingMounts || !mounts}
|
||||
>
|
||||
Translate & Apply
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VaultPolicyImportModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Import from HashiCorp Vault"
|
||||
subTitle="Select a policy from your Vault namespace or paste your own HCL policy to translate it into Infisical permissions."
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type VaultMount = {
|
||||
path: string;
|
||||
type: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
type ArrayElement<T> = T extends (infer U)[] ? U : never;
|
||||
|
||||
export type SecretPermissionRule = ArrayElement<
|
||||
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.Secrets]
|
||||
>;
|
||||
|
||||
export type FolderPermissionRule = ArrayElement<
|
||||
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.SecretFolders]
|
||||
>;
|
||||
|
||||
type ParsedVaultPath = {
|
||||
environment: string | null;
|
||||
secretPath: string | null;
|
||||
mount: VaultMount | null;
|
||||
isWildcardMount: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Path Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parses a Vault policy path to extract mount, environment, and secret path.
|
||||
*
|
||||
* Handles three types of path patterns:
|
||||
* 1. Global wildcards: "*" or "+" → matches all mounts, environments, paths
|
||||
* 2. Wildcard mounts: "* /data/prod/*" → matches all mounts with specific path
|
||||
* 3. Regular paths: "secret/data/prod/api-keys" → specific mount and path
|
||||
*
|
||||
* For KV v2 mounts:
|
||||
* - data/ paths → secret operations (read, write values)
|
||||
* - metadata/ paths → folder operations (create, delete folders)
|
||||
*
|
||||
* Path structure after mount:
|
||||
* - KV v2: [data|metadata]/{environment}/{secretPath}
|
||||
* - KV v1: {environment}/{secretPath}
|
||||
*/
|
||||
export const parseVaultPath = (vaultPath: string, mounts: VaultMount[]): ParsedVaultPath => {
|
||||
// Case 1: Global wildcard (e.g., "*" or "+") - matches everything
|
||||
if (vaultPath === "*" || vaultPath === "+") {
|
||||
const syntheticMount: VaultMount = {
|
||||
path: "*",
|
||||
type: "kv",
|
||||
version: "1" // Default to v1 for global wildcards
|
||||
};
|
||||
return {
|
||||
environment: "*",
|
||||
secretPath: "/*",
|
||||
mount: syntheticMount,
|
||||
isWildcardMount: true
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Wildcard mount (e.g., "*/data/*") - matches any mount with pattern
|
||||
const isWildcardMount = vaultPath.startsWith("*/") || vaultPath.startsWith("+/");
|
||||
|
||||
if (isWildcardMount) {
|
||||
let remainingPath = vaultPath.slice(2); // Remove "*/" or "+/"
|
||||
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
|
||||
|
||||
let environment: string | null = null;
|
||||
let secretPath: string | null = null;
|
||||
let isDataPath = false;
|
||||
let isMetadataPath = false;
|
||||
|
||||
// Check for KV v2 data/ or metadata/ prefix
|
||||
if (remainingPath.startsWith("data/")) {
|
||||
isDataPath = true;
|
||||
remainingPath = remainingPath.slice(5);
|
||||
} else if (remainingPath.startsWith("metadata/")) {
|
||||
isMetadataPath = true;
|
||||
remainingPath = remainingPath.slice(9);
|
||||
}
|
||||
|
||||
// Parse remaining segments
|
||||
const segments = remainingPath.split("/").filter(Boolean);
|
||||
|
||||
if (segments.length > 0) {
|
||||
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
|
||||
environment = "*";
|
||||
secretPath = "/*";
|
||||
} else {
|
||||
[environment] = segments;
|
||||
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
|
||||
}
|
||||
}
|
||||
|
||||
// Create synthetic mount based on detected version
|
||||
const syntheticMount: VaultMount = {
|
||||
path: "*",
|
||||
type: "kv",
|
||||
version: isDataPath || isMetadataPath ? "2" : "1"
|
||||
};
|
||||
|
||||
return { environment, secretPath, mount: syntheticMount, isWildcardMount: true };
|
||||
}
|
||||
|
||||
// Case 3: Regular path (e.g., "secret/data/prod/api-keys")
|
||||
// Find matching mount (longest path first for most specific match)
|
||||
const sortedMounts = [...mounts].sort((a, b) => b.path.length - a.path.length);
|
||||
const mount = sortedMounts.find((m) => vaultPath.startsWith(m.path));
|
||||
|
||||
if (!mount) {
|
||||
return { environment: null, secretPath: null, mount: null, isWildcardMount: false };
|
||||
}
|
||||
|
||||
// Remove mount prefix
|
||||
let remainingPath = vaultPath.slice(mount.path.length);
|
||||
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
|
||||
|
||||
const isKvV2 = mount.version === "2" || mount.type === "kv";
|
||||
|
||||
// For KV v2, remove data/ or metadata/ prefix
|
||||
if (isKvV2) {
|
||||
if (remainingPath.startsWith("data/")) {
|
||||
remainingPath = remainingPath.slice(5);
|
||||
} else if (remainingPath.startsWith("metadata/")) {
|
||||
remainingPath = remainingPath.slice(9);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse environment and secret path
|
||||
const segments = remainingPath.split("/").filter(Boolean);
|
||||
let environment: string | null = null;
|
||||
let secretPath: string | null = null;
|
||||
|
||||
if (segments.length > 0) {
|
||||
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
|
||||
// Single wildcard segment
|
||||
environment = null;
|
||||
secretPath = "/*";
|
||||
} else {
|
||||
// First segment is the environment
|
||||
[environment] = segments;
|
||||
// Remaining segments form the secret path
|
||||
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
|
||||
}
|
||||
}
|
||||
|
||||
return { environment, secretPath, mount, isWildcardMount: false };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Capability Mapping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maps Vault capabilities to Infisical secret actions.
|
||||
*
|
||||
* Mapping:
|
||||
* - create → Create
|
||||
* - list → DescribeSecret (view metadata without values)
|
||||
* - read → DescribeSecret + ReadValue (full access)
|
||||
* - update/patch → Edit
|
||||
* - delete → Delete
|
||||
*/
|
||||
const mapVaultCapabilitiesToSecretActions = (capabilities: string[]): Record<string, boolean> => {
|
||||
const actions: Record<string, boolean> = {};
|
||||
|
||||
if (capabilities.includes("create")) {
|
||||
actions[ProjectPermissionSecretActions.Create] = true;
|
||||
}
|
||||
if (capabilities.includes("list")) {
|
||||
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
|
||||
}
|
||||
if (capabilities.includes("read")) {
|
||||
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
|
||||
actions[ProjectPermissionSecretActions.ReadValue] = true;
|
||||
}
|
||||
if (capabilities.includes("update") || capabilities.includes("patch")) {
|
||||
actions[ProjectPermissionSecretActions.Edit] = true;
|
||||
}
|
||||
if (capabilities.includes("delete")) {
|
||||
actions[ProjectPermissionSecretActions.Delete] = true;
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Vault capabilities to Infisical folder actions.
|
||||
*
|
||||
* Mapping:
|
||||
* - create → Create
|
||||
* - update/patch → Edit
|
||||
* - delete → Delete
|
||||
*
|
||||
* Note: 'list' is not mapped for folders as it's handled at the secret level
|
||||
*/
|
||||
const mapVaultCapabilitiesToFolderActions = (capabilities: string[]): Record<string, boolean> => {
|
||||
const actions: Record<string, boolean> = {};
|
||||
|
||||
if (capabilities.includes("create")) {
|
||||
actions[ProjectPermissionActions.Create] = true;
|
||||
}
|
||||
if (capabilities.includes("update") || capabilities.includes("patch")) {
|
||||
actions[ProjectPermissionActions.Edit] = true;
|
||||
}
|
||||
if (capabilities.includes("delete")) {
|
||||
actions[ProjectPermissionActions.Delete] = true;
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Condition Building
|
||||
// ============================================================================
|
||||
|
||||
type PermissionCondition = {
|
||||
lhs: string;
|
||||
operator: string;
|
||||
rhs: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Vault wildcard patterns to Infisical glob patterns.
|
||||
* - Vault '+' → picomatch '*' (matches single segment)
|
||||
* - Vault '*' → picomatch '**' (matches any depth)
|
||||
*/
|
||||
const convertVaultWildcardToGlob = (vaultPattern: string): string => {
|
||||
// Use a placeholder to avoid replacing + twice
|
||||
// Step 1: Replace + with a placeholder
|
||||
let result = vaultPattern.replace(/\+/g, "__PLUS__");
|
||||
// Step 2: Replace * with **
|
||||
result = result.replace(/\*/g, "**");
|
||||
// Step 3: Replace placeholder with *
|
||||
result = result.replace(/__PLUS__/g, "*");
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds permission conditions for environment and secret path filtering.
|
||||
* Returns empty array if no restrictions are needed (matches everything).
|
||||
*/
|
||||
const buildConditions = (
|
||||
environment: string | null,
|
||||
secretPath: string | null
|
||||
): PermissionCondition[] => {
|
||||
const conditions: PermissionCondition[] = [];
|
||||
|
||||
// Add environment condition if present and not matching everything
|
||||
if (environment) {
|
||||
const globEnv = convertVaultWildcardToGlob(environment);
|
||||
// Skip if matches everything (Vault * becomes **)
|
||||
if (globEnv !== "**") {
|
||||
const hasWildcard = globEnv.includes("*");
|
||||
conditions.push({
|
||||
lhs: "environment",
|
||||
operator: hasWildcard
|
||||
? PermissionConditionOperators.$GLOB
|
||||
: PermissionConditionOperators.$EQ,
|
||||
rhs: globEnv
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add secret path condition if present and not matching everything
|
||||
if (secretPath && secretPath !== "/*") {
|
||||
const globPath = convertVaultWildcardToGlob(secretPath);
|
||||
// After conversion, /* becomes /** which matches everything
|
||||
if (globPath !== "/**") {
|
||||
const hasWildcard = globPath.includes("*");
|
||||
conditions.push({
|
||||
lhs: "secretPath",
|
||||
operator: hasWildcard
|
||||
? PermissionConditionOperators.$GLOB
|
||||
: PermissionConditionOperators.$EQ,
|
||||
rhs: globPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conditions;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Rule Deduplication
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a unique key for deduplication of permission rules.
|
||||
* Combines all actions and conditions into a single string identifier.
|
||||
*/
|
||||
const createPermissionRuleKey = (rule: SecretPermissionRule | FolderPermissionRule): string => {
|
||||
const actions = Object.entries(rule)
|
||||
.filter(([key]) => key !== "conditions")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join("|");
|
||||
|
||||
const conditions = (rule.conditions || [])
|
||||
.map((c) => `${c.lhs}${c.operator}${c.rhs}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return `${actions}::${conditions}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a permission rule to the list if it's not a duplicate.
|
||||
*/
|
||||
const addPermissionRuleIfUnique = <T extends SecretPermissionRule | FolderPermissionRule>(
|
||||
rule: T,
|
||||
rulesList: T[],
|
||||
seenRules: Set<string>
|
||||
): void => {
|
||||
const ruleKey = createPermissionRuleKey(rule);
|
||||
if (!seenRules.has(ruleKey)) {
|
||||
seenRules.add(ruleKey);
|
||||
rulesList.push(rule);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Parser
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parses Vault HCL policy and converts it to Infisical permissions.
|
||||
*
|
||||
* Process:
|
||||
* 1. Clean HCL (remove comments, whitespace)
|
||||
* 2. Extract path blocks with regex
|
||||
* 3. For each path:
|
||||
* - Parse to extract mount, environment, and secret path
|
||||
* - Determine if it's a data path (secrets) or metadata path (folders)
|
||||
* - Map Vault capabilities to Infisical actions
|
||||
* - Build conditions for environment and path filtering
|
||||
* - Create permission rule and add if unique
|
||||
*
|
||||
* @param hclPolicy - Raw Vault HCL policy string
|
||||
* @param mounts - List of Vault mounts to match paths against
|
||||
* @returns Parsed permissions object ready for Infisical role creation
|
||||
*/
|
||||
export const parseVaultPolicyToInfisical = (
|
||||
hclPolicy: string,
|
||||
mounts: VaultMount[]
|
||||
): Partial<TFormSchema["permissions"]> => {
|
||||
const secretsPermissions: SecretPermissionRule[] = [];
|
||||
const foldersPermissions: FolderPermissionRule[] = [];
|
||||
|
||||
const seenSecretRules = new Set<string>();
|
||||
const seenFolderRules = new Set<string>();
|
||||
|
||||
try {
|
||||
// Step 1: Clean HCL policy - remove comments and extra whitespace
|
||||
const cleanedPolicy = hclPolicy
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/#.*$/, "").trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join(" ");
|
||||
|
||||
// Step 2: Extract path blocks using regex
|
||||
const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi;
|
||||
let match = pathRegex.exec(cleanedPolicy);
|
||||
|
||||
// Step 3: Process each path block
|
||||
while (match !== null) {
|
||||
const [, path, capabilitiesStr] = match;
|
||||
|
||||
// Parse capabilities list
|
||||
const capabilities = capabilitiesStr
|
||||
.split(",")
|
||||
.map((c) => c.trim().replace(/["'\s]/g, ""))
|
||||
.filter((c) => c.length > 0);
|
||||
|
||||
// Parse the Vault path
|
||||
const { environment, secretPath, mount } = parseVaultPath(path, mounts);
|
||||
|
||||
// Only process KV (Key-Value) secret engines
|
||||
if (mount && (mount.type === "kv" || mount.type === "generic")) {
|
||||
const isKvV2 = mount.version === "2";
|
||||
const isMetadata = isKvV2 && path.includes("/metadata/");
|
||||
|
||||
if (isMetadata) {
|
||||
// Metadata paths → Folder permissions only (KV v2 metadata endpoint)
|
||||
const actions = mapVaultCapabilitiesToFolderActions(capabilities);
|
||||
if (Object.keys(actions).length > 0) {
|
||||
const conditions = buildConditions(environment, secretPath);
|
||||
addPermissionRuleIfUnique(
|
||||
{ ...actions, conditions },
|
||||
foldersPermissions,
|
||||
seenFolderRules
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Data paths → Both secret AND folder permissions (KV v1 and v2 data paths)
|
||||
// Users need both to fully manage secrets and their containing folders
|
||||
const conditions = buildConditions(environment, secretPath);
|
||||
|
||||
// Create secret permissions
|
||||
const secretActions = mapVaultCapabilitiesToSecretActions(capabilities);
|
||||
if (Object.keys(secretActions).length > 0) {
|
||||
addPermissionRuleIfUnique(
|
||||
{ ...secretActions, conditions },
|
||||
secretsPermissions,
|
||||
seenSecretRules
|
||||
);
|
||||
}
|
||||
|
||||
// Create folder permissions for create/update/delete capabilities
|
||||
const folderActions = mapVaultCapabilitiesToFolderActions(capabilities);
|
||||
if (Object.keys(folderActions).length > 0) {
|
||||
addPermissionRuleIfUnique(
|
||||
{ ...folderActions, conditions },
|
||||
foldersPermissions,
|
||||
seenFolderRules
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match = pathRegex.exec(cleanedPolicy);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error parsing HCL policy:", err);
|
||||
}
|
||||
|
||||
// Build final permissions object
|
||||
const permissions: Partial<TFormSchema["permissions"]> = {};
|
||||
if (secretsPermissions.length > 0) {
|
||||
permissions[ProjectPermissionSub.Secrets] = secretsPermissions;
|
||||
}
|
||||
if (foldersPermissions.length > 0) {
|
||||
permissions[ProjectPermissionSub.SecretFolders] = foldersPermissions;
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
@@ -77,6 +77,11 @@ import {
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import {
|
||||
useGetVaultExternalMigrationConfigs,
|
||||
useImportVaultSecrets
|
||||
} from "@app/hooks/api/migration";
|
||||
import { VaultImportStatus } from "@app/hooks/api/migration/types";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
@@ -98,6 +103,7 @@ import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
import { VaultSecretImportModal } from "./VaultSecretImportModal";
|
||||
|
||||
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
||||
type TParsedFolderEnv = Record<
|
||||
@@ -171,7 +177,8 @@ export const ActionBar = ({
|
||||
"upgradePlan",
|
||||
"replicateFolder",
|
||||
"confirmUpload",
|
||||
"requestAccess"
|
||||
"requestAccess",
|
||||
"importFromVault"
|
||||
] as const);
|
||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||
const { subscription } = useSubscription();
|
||||
@@ -185,6 +192,7 @@ export const ActionBar = ({
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: importVaultSecrets } = useImportVaultSecrets();
|
||||
const queryClient = useQueryClient();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
@@ -193,6 +201,8 @@ export const ActionBar = ({
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
try {
|
||||
@@ -663,6 +673,40 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleVaultImport = async (vaultPath: string, namespace: string) => {
|
||||
try {
|
||||
const result = await importVaultSecrets({
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
vaultNamespace: namespace,
|
||||
vaultSecretPath: vaultPath
|
||||
});
|
||||
|
||||
if (result.status === VaultImportStatus.ApprovalRequired) {
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "Secret change request created successfully. Awaiting approval."
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully imported secrets from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Vault import error:", err);
|
||||
const error = err as AxiosError<{ message?: string }>;
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Failed to import secrets from Vault. Please try again.";
|
||||
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: errorMessage
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isTableFiltered =
|
||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||
|
||||
@@ -1059,6 +1103,39 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{hasVaultConnection && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("importFromVault");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Add from HashiCorp Vault
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1277,6 +1354,13 @@ export const ActionBar = ({
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<VaultSecretImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
onImport={handleVaultImport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useGetVaultMounts,
|
||||
useGetVaultNamespaces,
|
||||
useGetVaultSecretPaths
|
||||
} from "@app/hooks/api/migration/queries";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onImport: (vaultPath: string, namespace: string) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onImport: (vaultPath: string, namespace: string) => void;
|
||||
};
|
||||
|
||||
const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => {
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
|
||||
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [shouldFetchPaths, setShouldFetchPaths] = useState(false);
|
||||
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
|
||||
const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths(
|
||||
shouldFetchPaths,
|
||||
selectedNamespace ?? undefined,
|
||||
selectedMountPath ?? undefined
|
||||
);
|
||||
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
|
||||
shouldFetchMounts,
|
||||
selectedNamespace ?? undefined
|
||||
);
|
||||
|
||||
// Filter to only show KV mounts
|
||||
const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
|
||||
|
||||
// Enable fetching mounts when namespace is selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchMounts(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
// Enable fetching paths when both namespace and mount path are selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace && selectedMountPath) {
|
||||
setShouldFetchPaths(true);
|
||||
} else {
|
||||
setShouldFetchPaths(false);
|
||||
}
|
||||
}, [selectedNamespace, selectedMountPath]);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!selectedPath) {
|
||||
createNotification({ type: "error", text: "Please select a Vault secret path to import" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
createNotification({ type: "error", text: "Please select a namespace" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounts || mounts.length === 0) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(selectedPath, selectedNamespace);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<strong>Import Secrets from HashiCorp Vault</strong>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed">
|
||||
<p>
|
||||
Select a Vault namespace and secret path to import secrets into the current
|
||||
Infisical environment (<code className="text-xs">{environment}</code>) at path{" "}
|
||||
<code className="text-xs">{secretPath}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
className="mb-4"
|
||||
tooltipText="Select the Vault namespace containing the secrets you want to import."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={namespaces?.find((ns) => ns.name === selectedNamespace)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const namespace = value as { id: string; name: string };
|
||||
setSelectedNamespace(namespace.name);
|
||||
setSelectedMountPath(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select the Vault namespace to fetch available mounts
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Secrets Engine"
|
||||
className="mb-4"
|
||||
tooltipText="Select the KV secrets engine to narrow down secret paths."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={kvMounts?.find((mount) => mount.path === selectedMountPath)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const mount = value as { path: string; type: string; version: string | null };
|
||||
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={kvMounts || []}
|
||||
getOptionValue={(option) => option.path}
|
||||
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
|
||||
isDisabled={isLoadingMounts || !kvMounts?.length}
|
||||
placeholder="Select secrets engine..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Choose a KV secrets engine to filter available secret paths
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Vault Secret Path" className="mb-6">
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={selectedPath ? { path: selectedPath } : null}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
setSelectedPath((value as { path: string }).path);
|
||||
} else {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={(secretPaths || []).map((path) => ({ path }))}
|
||||
getOptionValue={(option) => option.path}
|
||||
getOptionLabel={(option) => option.path}
|
||||
isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath}
|
||||
placeholder={
|
||||
!selectedMountPath
|
||||
? "Select a mount path first..."
|
||||
: "Select a Vault path to import..."
|
||||
}
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Choose a secret path from the selected mount to import into Infisical
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
isDisabled={!selectedPath || isLoadingMounts || isLoadingPaths}
|
||||
>
|
||||
Import Secrets
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VaultSecretImportModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
environment,
|
||||
secretPath,
|
||||
onImport
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Import from HashiCorp Vault"
|
||||
subTitle="Select a Vault namespace and secret path to import secrets into the current environment and folder."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<Content
|
||||
onClose={() => onOpenChange(false)}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
onImport={onImport}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||