Merge pull request #4667 from Infisical/feat/in-platform-vault-migration-tooling
feat: in-platform migration tooling for Vault policies + scaffolding
@@ -518,6 +518,9 @@ import {
|
|||||||
TUsers,
|
TUsers,
|
||||||
TUsersInsert,
|
TUsersInsert,
|
||||||
TUsersUpdate,
|
TUsersUpdate,
|
||||||
|
TVaultExternalMigrationConfigs,
|
||||||
|
TVaultExternalMigrationConfigsInsert,
|
||||||
|
TVaultExternalMigrationConfigsUpdate,
|
||||||
TWebhooks,
|
TWebhooks,
|
||||||
TWebhooksInsert,
|
TWebhooksInsert,
|
||||||
TWebhooksUpdate,
|
TWebhooksUpdate,
|
||||||
@@ -1345,5 +1348,10 @@ declare module "knex/types/tables" {
|
|||||||
TAdditionalPrivilegesInsert,
|
TAdditionalPrivilegesInsert,
|
||||||
TAdditionalPrivilegesUpdate
|
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-encryption-keys";
|
||||||
export * from "./user-group-membership";
|
export * from "./user-group-membership";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./vault-external-migration-configs";
|
||||||
export * from "./webhooks";
|
export * from "./webhooks";
|
||||||
export * from "./workflow-integrations";
|
export * from "./workflow-integrations";
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ export enum TableName {
|
|||||||
PamFolder = "pam_folders",
|
PamFolder = "pam_folders",
|
||||||
PamResource = "pam_resources",
|
PamResource = "pam_resources",
|
||||||
PamAccount = "pam_accounts",
|
PamAccount = "pam_accounts",
|
||||||
PamSession = "pam_sessions"
|
PamSession = "pam_sessions",
|
||||||
|
|
||||||
|
VaultExternalMigrationConfig = "vault_external_migration_configs"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||||
|
|||||||
@@ -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 { 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 { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
|
||||||
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
|
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 { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
|
||||||
import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal";
|
import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal";
|
||||||
import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
|
import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
|
||||||
@@ -534,6 +535,8 @@ export const registerRoutes = async (
|
|||||||
const membershipRoleDAL = membershipRoleDALFactory(db);
|
const membershipRoleDAL = membershipRoleDALFactory(db);
|
||||||
const roleDAL = roleDALFactory(db);
|
const roleDAL = roleDALFactory(db);
|
||||||
|
|
||||||
|
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
|
||||||
|
|
||||||
const eventBusService = eventBusFactory(server.redis);
|
const eventBusService = eventBusFactory(server.redis);
|
||||||
const sseService = sseServiceFactory(eventBusService, server.redis);
|
const sseService = sseServiceFactory(eventBusService, server.redis);
|
||||||
|
|
||||||
@@ -1882,13 +1885,6 @@ export const registerRoutes = async (
|
|||||||
notificationService
|
notificationService
|
||||||
});
|
});
|
||||||
|
|
||||||
const migrationService = externalMigrationServiceFactory({
|
|
||||||
externalMigrationQueue,
|
|
||||||
userDAL,
|
|
||||||
permissionService,
|
|
||||||
gatewayService
|
|
||||||
});
|
|
||||||
|
|
||||||
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
licenseService,
|
licenseService,
|
||||||
@@ -2205,6 +2201,18 @@ export const registerRoutes = async (
|
|||||||
kmsService
|
kmsService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const migrationService = externalMigrationServiceFactory({
|
||||||
|
externalMigrationQueue,
|
||||||
|
userDAL,
|
||||||
|
permissionService,
|
||||||
|
gatewayService,
|
||||||
|
kmsService,
|
||||||
|
appConnectionService,
|
||||||
|
vaultExternalMigrationConfigDAL,
|
||||||
|
secretService,
|
||||||
|
auditLogService
|
||||||
|
});
|
||||||
|
|
||||||
// setup the communication with license key server
|
// setup the communication with license key server
|
||||||
await licenseService.init();
|
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 { AuthMode } from "@app/services/auth/auth-type";
|
||||||
import {
|
import {
|
||||||
ExternalMigrationProviders,
|
ExternalMigrationProviders,
|
||||||
|
VaultImportStatus,
|
||||||
VaultMappingType
|
VaultMappingType
|
||||||
} from "@app/services/external-migration/external-migration-types";
|
} from "@app/services/external-migration/external-migration-types";
|
||||||
|
|
||||||
@@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
|
|||||||
return { enabled };
|
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",
|
AccessToken = "access-token",
|
||||||
AppRole = "app-role"
|
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 { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
|
||||||
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
||||||
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
|
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) => {
|
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
||||||
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
|
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,
|
connection: THCVaultConnection,
|
||||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
) => {
|
) => {
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
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, {
|
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
|
||||||
url: `${instanceUrl}/v1/sys/mounts`,
|
url: `${instanceUrl}/v1/sys/mounts`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Vault-Token": accessToken,
|
"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]) => {
|
Object.entries(data.data).forEach(([path, mount]) => {
|
||||||
if (mount.type === "kv" && mount.options?.version === "2") {
|
mounts.push({
|
||||||
mounts.push(path);
|
path,
|
||||||
}
|
type: mount.type,
|
||||||
|
version: mount.options?.version
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return mounts;
|
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 {
|
try {
|
||||||
const mounts = await listHCVaultMounts(appConnection, gatewayService);
|
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) {
|
} catch (error) {
|
||||||
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
||||||
return [];
|
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 { 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 { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
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 { TUserDALFactory } from "../user/user-dal";
|
||||||
import {
|
import {
|
||||||
decryptEnvKeyDataFn,
|
decryptEnvKeyDataFn,
|
||||||
@@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue";
|
|||||||
import {
|
import {
|
||||||
ExternalMigrationProviders,
|
ExternalMigrationProviders,
|
||||||
ExternalPlatforms,
|
ExternalPlatforms,
|
||||||
|
TCreateVaultExternalMigrationDTO,
|
||||||
|
TDeleteVaultExternalMigrationDTO,
|
||||||
THasCustomVaultMigrationDTO,
|
THasCustomVaultMigrationDTO,
|
||||||
TImportEnvKeyDataDTO,
|
TImportEnvKeyDataDTO,
|
||||||
TImportVaultDataDTO
|
TImportVaultDataDTO,
|
||||||
|
TUpdateVaultExternalMigrationDTO,
|
||||||
|
VaultImportStatus
|
||||||
} from "./external-migration-types";
|
} from "./external-migration-types";
|
||||||
|
import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal";
|
||||||
|
|
||||||
type TExternalMigrationServiceFactoryDep = {
|
type TExternalMigrationServiceFactoryDep = {
|
||||||
permissionService: TPermissionServiceFactory;
|
permissionService: TPermissionServiceFactory;
|
||||||
|
secretService: TSecretServiceFactory;
|
||||||
|
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||||
externalMigrationQueue: TExternalMigrationQueueFactory;
|
externalMigrationQueue: TExternalMigrationQueueFactory;
|
||||||
|
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||||
|
vaultExternalMigrationConfigDAL: Pick<
|
||||||
|
TVaultExternalMigrationConfigDALFactory,
|
||||||
|
"create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById"
|
||||||
|
>;
|
||||||
userDAL: Pick<TUserDALFactory, "findById">;
|
userDAL: Pick<TUserDALFactory, "findById">;
|
||||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||||
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
||||||
@@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({
|
|||||||
permissionService,
|
permissionService,
|
||||||
externalMigrationQueue,
|
externalMigrationQueue,
|
||||||
userDAL,
|
userDAL,
|
||||||
gatewayService
|
gatewayService,
|
||||||
|
secretService,
|
||||||
|
auditLogService,
|
||||||
|
appConnectionService,
|
||||||
|
vaultExternalMigrationConfigDAL,
|
||||||
|
kmsService
|
||||||
}: TExternalMigrationServiceFactoryDep) => {
|
}: TExternalMigrationServiceFactoryDep) => {
|
||||||
const importEnvKeyData = async ({
|
const importEnvKeyData = async ({
|
||||||
decryptionKey,
|
decryptionKey,
|
||||||
@@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({
|
|||||||
return actorOrgId in vaultMigrationTransformMappings;
|
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 {
|
return {
|
||||||
importEnvKeyData,
|
importEnvKeyData,
|
||||||
importVaultData,
|
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";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
@@ -121,3 +121,26 @@ export enum ExternalMigrationProviders {
|
|||||||
Vault = "vault",
|
Vault = "vault",
|
||||||
EnvKey = "env-key"
|
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,12 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: "External Migrations"
|
title: "External Migrations"
|
||||||
sidebarTitle: "Overview"
|
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
|
## 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
|
## Supported Platforms
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,239 @@
|
|||||||
---
|
---
|
||||||
title: "Migrating from Vault to Infisical"
|
title: "Migrating from Vault to Infisical"
|
||||||
sidebarTitle: "Vault"
|
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.
|
- Migrate all secrets from Vault in one go
|
||||||
- An access token to your Vault instance.
|
- Automatically create new Infisical projects from your Vault structure
|
||||||
|
- Perform a one-time migration when moving entirely from Vault to Infisical
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
|
## 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>
|
<Steps>
|
||||||
<Step title="Create a Vault policy">
|
<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
|
# Allow listing secret engines/mounts
|
||||||
path "sys/mounts" {
|
path "sys/mounts" {
|
||||||
capabilities = ["read", "list"]
|
capabilities = ["read", "list"]
|
||||||
@@ -63,65 +262,54 @@ There are two options for project mapping:
|
|||||||
capabilities = ["read", "list"]
|
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>
|
||||||
|
|
||||||
<Step title="Generate an access token">
|
<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
|
```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>
|
||||||
|
|
||||||
<Step title="Navigate to Infisical external migrations">
|
<Step title="Start the import in Infisical">
|
||||||
Open the Infisical dashboard and go to Organization Settings > External Migrations.
|
In Infisical, navigate to **Organization Settings > External Migrations**.
|
||||||
|
|
||||||

|

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

|

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

|

|
||||||
|
|
||||||
- `Vault URL`: The URL of your Vault instance.
|
- **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`)
|
||||||
- `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 Namespace**: Optional - only needed if using Vault Enterprise namespaces
|
||||||
- `Vault Access Token`: The access token that you generated in the previous step.
|
- **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>
|
</Step>
|
||||||
</Steps>
|
</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 "./mutations";
|
||||||
export * from "./queries";
|
export * from "./queries";
|
||||||
|
export * from "./types";
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
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 { projectKeys } from "../projects";
|
||||||
|
import { externalMigrationQueryKeys } from "./queries";
|
||||||
|
import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types";
|
||||||
|
|
||||||
export const useImportEnvKey = () => {
|
export const useImportEnvKey = () => {
|
||||||
const queryClient = useQueryClient();
|
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 { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { ExternalMigrationProviders } from "./types";
|
import {
|
||||||
|
ExternalMigrationProviders,
|
||||||
|
TVaultExternalMigrationConfig,
|
||||||
|
VaultKubernetesAuthRole
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const externalMigrationQueryKeys = {
|
export const externalMigrationQueryKeys = {
|
||||||
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
||||||
"custom-migration-available",
|
"custom-migration-available",
|
||||||
provider
|
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",
|
Vault = "vault",
|
||||||
EnvKey = "env-key"
|
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 { useEffect, useState } from "react";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -37,9 +37,12 @@ import {
|
|||||||
IdentityKubernetesAuthTokenReviewMode,
|
IdentityKubernetesAuthTokenReviewMode,
|
||||||
IdentityTrustedIp
|
IdentityTrustedIp
|
||||||
} from "@app/hooks/api/identities/types";
|
} 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 { IdentityFormTab } from "./types";
|
||||||
|
import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal";
|
||||||
|
|
||||||
const schema = z
|
const schema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -121,6 +124,12 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
enabled: isUpdate
|
enabled: isUpdate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
|
||||||
|
"importFromVault"
|
||||||
|
] as const);
|
||||||
|
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||||
|
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -192,6 +201,99 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
}
|
}
|
||||||
}, [data]);
|
}, [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 ({
|
const onFormSubmit = async ({
|
||||||
kubernetesHost,
|
kubernetesHost,
|
||||||
tokenReviewerJwt,
|
tokenReviewerJwt,
|
||||||
@@ -301,6 +403,28 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={IdentityFormTab.Configuration}>
|
<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="flex w-full items-center gap-2">
|
||||||
<div className="w-full flex-1">
|
<div className="w-full flex-1">
|
||||||
<OrgPermissionCan
|
<OrgPermissionCan
|
||||||
@@ -407,6 +531,7 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
placeholder="https://my-example-k8s-api-host.com"
|
placeholder="https://my-example-k8s-api-host.com"
|
||||||
type="text"
|
type="text"
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
@@ -425,7 +550,7 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
errorText={error?.message}
|
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."
|
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>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -441,7 +566,12 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
|
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>
|
</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."
|
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}
|
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>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -628,6 +762,11 @@ export const IdentityKubernetesAuthForm = ({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<VaultKubernetesAuthImportModal
|
||||||
|
isOpen={popUp.importFromVault.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handleImportPopUpToggle("importFromVault", isOpen)}
|
||||||
|
onImport={handleImportFromVault}
|
||||||
|
/>
|
||||||
</form>
|
</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 { usePopUp } from "@app/hooks";
|
||||||
|
|
||||||
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
||||||
|
import { VaultConnectionSection } from "./components/VaultConnectionSection";
|
||||||
|
|
||||||
export const ExternalMigrationsTab = () => {
|
export const ExternalMigrationsTab = () => {
|
||||||
const { hasOrgRole } = useOrgPermission();
|
const { hasOrgRole } = useOrgPermission();
|
||||||
@@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => {
|
|||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex justify-between">
|
{/* In-Platform Migration Tooling Section */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Import from external source</p>
|
<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>
|
{/* Bulk Data Import Section */}
|
||||||
<a
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
target="_blank"
|
<div className="mb-4">
|
||||||
rel="noopener noreferrer"
|
<h2 className="text-xl font-medium text-mineshaft-100">Bulk Data Import</h2>
|
||||||
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
<p className="mt-1 mb-6 text-sm text-gray-400">
|
||||||
>
|
Perform one-time bulk imports of data from external platforms.
|
||||||
<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">
|
</p>
|
||||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
|
||||||
Docs
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faArrowUpRightFromSquare}
|
|
||||||
className="text-xxs mb-[0.07rem] ml-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-start justify-between">
|
||||||
onClick={() => {
|
<div>
|
||||||
handlePopUpOpen("selectImportPlatform");
|
<div className="flex items-center gap-2">
|
||||||
}}
|
<p className="text-base font-medium text-mineshaft-100">
|
||||||
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
|
Import from external source
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
</p>
|
||||||
>
|
<a
|
||||||
Import
|
target="_blank"
|
||||||
</Button>
|
rel="noopener noreferrer"
|
||||||
</div>
|
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
||||||
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
|
>
|
||||||
|
<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
|
<Button
|
||||||
isOpen={popUp.selectImportPlatform.isOpen}
|
onClick={() => {
|
||||||
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
|
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>
|
</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,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
IconButton
|
IconButton,
|
||||||
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
|
import { useOrgPermission } from "@app/context";
|
||||||
|
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration";
|
||||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||||
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
|
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
|
||||||
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
|
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
|
||||||
|
import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
@@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
"addPolicy",
|
"addPolicy",
|
||||||
"addPolicyOptions",
|
"addPolicyOptions",
|
||||||
"applyTemplate"
|
"applyTemplate",
|
||||||
|
"importFromVault"
|
||||||
] as const);
|
] 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -64,6 +76,35 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
>
|
>
|
||||||
Add From Template
|
Add From Template
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
isOpen={popUp.applyTemplate.isOpen}
|
isOpen={popUp.applyTemplate.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
|
||||||
/>
|
/>
|
||||||
|
<VaultPolicyImportModal
|
||||||
|
isOpen={popUp.importFromVault.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
fetchDashboardProjectSecretsByKeys
|
||||||
} from "@app/hooks/api/dashboard/queries";
|
} from "@app/hooks/api/dashboard/queries";
|
||||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
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 { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||||
@@ -98,6 +103,7 @@ import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
|||||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||||
import { FolderForm } from "./FolderForm";
|
import { FolderForm } from "./FolderForm";
|
||||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||||
|
import { VaultSecretImportModal } from "./VaultSecretImportModal";
|
||||||
|
|
||||||
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
||||||
type TParsedFolderEnv = Record<
|
type TParsedFolderEnv = Record<
|
||||||
@@ -171,7 +177,8 @@ export const ActionBar = ({
|
|||||||
"upgradePlan",
|
"upgradePlan",
|
||||||
"replicateFolder",
|
"replicateFolder",
|
||||||
"confirmUpload",
|
"confirmUpload",
|
||||||
"requestAccess"
|
"requestAccess",
|
||||||
|
"importFromVault"
|
||||||
] as const);
|
] as const);
|
||||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
@@ -185,6 +192,7 @@ export const ActionBar = ({
|
|||||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||||
options: { onSuccess: undefined }
|
options: { onSuccess: undefined }
|
||||||
});
|
});
|
||||||
|
const { mutateAsync: importVaultSecrets } = useImportVaultSecrets();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { addPendingChange } = useBatchModeActions();
|
const { addPendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
@@ -193,6 +201,8 @@ export const ActionBar = ({
|
|||||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||||
|
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||||
|
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||||
|
|
||||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||||
try {
|
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 =
|
const isTableFiltered =
|
||||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||||
|
|
||||||
@@ -1059,6 +1103,39 @@ export const ActionBar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</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>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -1277,6 +1354,13 @@ export const ActionBar = ({
|
|||||||
</div>
|
</div>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||