diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts index a2bed759a..9cdad32fe 100644 --- a/backend/e2e-test/routes/v2/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -18,7 +18,7 @@ const createFolder = async (dto: { path: string; name: string }) => { return res.json().folder; }; -const deleteFolder = async (dto: { path: string; id: string }) => { +const deleteFolder = async (dto: { path: string; id: string; forceDelete?: boolean }) => { const res = await testServer.inject({ method: "DELETE", url: `/api/v2/folders/${dto.id}`, @@ -28,7 +28,8 @@ const deleteFolder = async (dto: { path: string; id: string }) => { body: { projectId: seedData1.project.id, environment: seedData1.environment.slug, - path: dto.path + path: dto.path, + forceDelete: dto.forceDelete ?? false } }); expect(res.statusCode).toBe(200); @@ -86,7 +87,7 @@ describe("Secret Folder Router", async () => { folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) }); - await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id, forceDelete: true }))); }); test("Update a deep folder", async () => { diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 07fe2c97d..619f7f92d 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -518,6 +518,9 @@ import { TUsers, TUsersInsert, TUsersUpdate, + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate, TWebhooks, TWebhooksInsert, TWebhooksUpdate, @@ -1345,5 +1348,10 @@ declare module "knex/types/tables" { TAdditionalPrivilegesInsert, TAdditionalPrivilegesUpdate >; + [TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType< + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts new file mode 100644 index 000000000..e5f74a0e0 --- /dev/null +++ b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig); + await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2a10e0f1b..6c718d097 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -176,5 +176,6 @@ export * from "./user-aliases"; export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; +export * from "./vault-external-migration-configs"; export * from "./webhooks"; export * from "./workflow-integrations"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index d3537b028..f7291a70f 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -203,7 +203,9 @@ export enum TableName { PamFolder = "pam_folders", PamResource = "pam_resources", PamAccount = "pam_accounts", - PamSession = "pam_sessions" + PamSession = "pam_sessions", + + VaultExternalMigrationConfig = "vault_external_migration_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/vault-external-migration-configs.ts b/backend/src/db/schemas/vault-external-migration-configs.ts new file mode 100644 index 000000000..310166669 --- /dev/null +++ b/backend/src/db/schemas/vault-external-migration-configs.ts @@ -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; +export type TVaultExternalMigrationConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TVaultExternalMigrationConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c7db12de9..6fcf05d99 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -129,7 +129,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { id: z.string(), value: z.string(), algorithm: z.string(), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }) } }, @@ -435,7 +435,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { key: z.string(), name: z.string(), algorithm: z.nativeEnum(SymmetricKeyAlgorithm), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }), response: { 200: z.object({ diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index c37d511c6..695f46942 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -78,7 +78,7 @@ export type TKmipRegisterDTO = { name: string; key: string; algorithm: SymmetricKeyAlgorithm; - kmipMetadata?: Record; + kmipMetadata?: Record | null; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index c2e67908f..fba9d0cca 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -214,6 +214,20 @@ export const licenseServiceFactory = ({ const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); currentPlan.identitiesUsed = identityUsed; + if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + try { + await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { + quantity: membersUsed, + quantityIdentities: identityUsed + }); + } catch (error) { + logger.error( + error, + `Update seats used: encountered an error when updating plan for customer [customerId=${org.customerId}]` + ); + } + } + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1799555fb..8d1ae45bf 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -122,16 +122,16 @@ export const IDENTITIES = { hasDeleteProtection: "Prevents deletion of the identity when enabled." }, UPDATE: { - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", name: "The new name of the identity.", role: "The new role of the identity.", hasDeleteProtection: "Prevents deletion of the identity when enabled." }, DELETE: { - identityId: "The ID of the identity to delete." + identityId: "The ID of the machine identity to delete." }, GET_BY_ID: { - identityId: "The ID of the identity to get details.", + identityId: "The ID of the machine identity to get details.", orgId: "The ID of the org of the identity" }, LIST: { @@ -156,7 +156,7 @@ export const UNIVERSAL_AUTH = { clientSecret: "Your Machine Identity Client Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", clientSecretTrustedIps: "A list of IPs or CIDR ranges that the Client Secret can be used from together with the Client ID to get back an access token. You can use 0.0.0.0/0, to allow usage from any network address.", accessTokenTrustedIps: @@ -175,13 +175,13 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", @@ -195,25 +195,25 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { - identityId: "The ID of the identity to create a client secret for.", + identityId: "The ID of the machine identity to create a client secret for.", description: "The description of the client secret.", numUsesLimit: "The maximum number of times that the client secret can be used; a value of 0 implies infinite number of uses.", ttl: "The lifetime for the client secret in seconds." }, LIST_CLIENT_SECRETS: { - identityId: "The ID of the identity to list client secrets for." + identityId: "The ID of the machine identity to list client secrets for." }, GET_CLIENT_SECRET: { - identityId: "The ID of the identity to get the client secret from.", + identityId: "The ID of the machine identity to get the client secret from.", clientSecretId: "The ID of the client secret to get details." }, REVOKE_CLIENT_SECRET: { - identityId: "The ID of the identity to revoke the client secret from.", + identityId: "The ID of the machine identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." @@ -225,13 +225,13 @@ export const UNIVERSAL_AUTH = { export const LDAP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", username: "The username of the LDAP user to login.", password: "The password of the LDAP user to login." }, ATTACH: { templateId: "The ID of the identity auth template to attach the configuration onto.", - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.", @@ -251,7 +251,7 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { - identityId: "The ID of the identity to update the configuration for.", + identityId: "The ID of the machine identity to update the configuration for.", url: "The new URL of the LDAP server.", allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.", searchBase: "The new base DN to search for the LDAP user.", @@ -271,19 +271,19 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the configuration for." + identityId: "The ID of the machine identity to retrieve the configuration for." }, REVOKE: { - identityId: "The ID of the identity to revoke the configuration for." + identityId: "The ID of the machine identity to revoke the configuration for." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." } } as const; export const ALICLOUD_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", Action: "The Alibaba Cloud API action. For STS GetCallerIdentity, this should be 'GetCallerIdentity'.", Format: "The response format. For STS GetCallerIdentity, this should be 'JSON'.", Version: "The API version. This should be in 'YYYY-MM-DD' format (e.g., '2015-04-01').", @@ -295,7 +295,7 @@ export const ALICLOUD_AUTH = { Signature: "The signature string calculated based on the request parameters and AccessKey Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -303,7 +303,7 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", @@ -311,19 +311,19 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TLS_CERT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -333,7 +333,7 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -343,16 +343,16 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AWS_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", iamHttpRequestMethod: "The HTTP request method used in the signed request.", iamRequestUrl: "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/.", @@ -361,7 +361,7 @@ export const AWS_AUTH = { iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedPrincipalArns: "The comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -373,7 +373,7 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedPrincipalArns: "The new comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -385,21 +385,21 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const OCI_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", userOcid: "The OCID of the user attempting login.", headers: "The headers of the signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -409,7 +409,7 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -419,19 +419,19 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AZURE_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenantId: "The tenant ID for the Azure AD organization.", resource: "The resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -442,7 +442,7 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenantId: "The new tenant ID for the Azure AD organization.", resource: "The new resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -453,19 +453,19 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const GCP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedServiceAccounts: "The comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -478,7 +478,7 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedServiceAccounts: "The new comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -491,19 +491,19 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const KUBERNETES_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", kubernetesHost: "The host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -522,7 +522,7 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", kubernetesHost: "The new host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -541,41 +541,41 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TOKEN_AUTH = { ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, GET_TOKENS: { - identityId: "The ID of the identity to list token metadata for.", + identityId: "The ID of the machine identity to list token metadata for.", offset: "The offset to start from. If you enter 10, it will start from the 10th token.", limit: "The number of tokens to return." }, CREATE_TOKEN: { - identityId: "The ID of the identity to create the token for.", + identityId: "The ID of the machine identity to create the token for.", name: "The name of the token to create." }, UPDATE_TOKEN: { @@ -589,10 +589,10 @@ export const TOKEN_AUTH = { export const OIDC_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", oidcDiscoveryUrl: "The URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The unique identifier of the identity provider issuing the JWT.", @@ -606,7 +606,7 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", oidcDiscoveryUrl: "The new URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The new PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The new unique identifier of the identity provider issuing the JWT.", @@ -620,19 +620,19 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const JWT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -649,7 +649,7 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -666,10 +666,10 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; @@ -853,12 +853,12 @@ export const PROJECT_IDENTITIES = { search: "The text string that identity membership names will be filtered by." }, GET_IDENTITY_MEMBERSHIP_BY_ID: { - identityId: "The ID of the identity to get the membership for.", + identityId: "The ID of the machine identity to get the membership for.", projectId: "The ID of the project to get the identity membership for." }, UPDATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to update the identity membership for.", - identityId: "The ID of the identity to update the membership for.", + identityId: "The ID of the machine identity to update the membership for.", roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", @@ -871,11 +871,11 @@ export const PROJECT_IDENTITIES = { }, DELETE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to delete the identity membership from.", - identityId: "The ID of the identity to delete the membership from." + identityId: "The ID of the machine identity to delete the membership from." }, CREATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to create the identity membership from.", - identityId: "The ID of the identity to create the membership from.", + identityId: "The ID of the machine identity to create the membership from.", role: "The role slug to assign to the newly created identity project membership.", roles: { description: "A list of role slugs to assign to the newly created identity project membership.", @@ -949,7 +949,8 @@ export const FOLDERS = { projectId: "The ID of the project to delete the folder from.", environment: "The slug of the environment where the folder is located.", directory: "The directory of the folder to delete. (Deprecated in favor of path)", - path: "The path of the folder to delete." + path: "The path of the folder to delete.", + forceDelete: "Whether to force delete the folder even if it contains resources." } } as const; @@ -1270,7 +1271,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to create.", + identityId: "The ID of the machine identity to create.", slug: "The slug of the privilege to create.", permissions: `@deprecated - use privilegePermission The permission object for the privilege. @@ -1296,7 +1297,7 @@ The permission object for the privilege. }, UPDATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", permissions: `@deprecated - use privilegePermission @@ -1322,17 +1323,17 @@ The permission object for the privilege. }, DELETE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, LIST: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", unpacked: "Whether the system should send the permissions as unpacked." } }; @@ -1374,7 +1375,7 @@ export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { CREATE: { - identityId: "The ID of the identity to create the privilege for.", + identityId: "The ID of the machine identity to create the privilege for.", projectId: "The ID of the project of the identity in.", slug: "The slug of the privilege to create.", permission: "The permission for the privilege.", @@ -1385,7 +1386,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, UPDATE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", privilegePermission: "The permission for the privilege.", isTemporary: "Whether the privilege is temporary.", @@ -1395,12 +1396,12 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, DELETE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, GET_BY_ID: { @@ -1408,7 +1409,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, LIST: { projectId: "The ID of the project that the identity is in.", - identityId: "The ID of the identity to list." + identityId: "The ID of the machine identity to list." } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e95dd501c..4a73a650c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -176,6 +176,7 @@ import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-gr import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal"; import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; @@ -534,6 +535,8 @@ export const registerRoutes = async ( const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); + const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); @@ -1344,7 +1347,8 @@ export const registerRoutes = async ( projectDAL, folderCommitService, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }); const secretImportService = secretImportServiceFactory({ @@ -1881,13 +1885,6 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, @@ -2204,6 +2201,18 @@ export const registerRoutes = async ( kmsService }); + const migrationService = externalMigrationServiceFactory({ + externalMigrationQueue, + userDAL, + permissionService, + gatewayService, + kmsService, + appConnectionService, + vaultExternalMigrationConfigDAL, + secretService, + auditLogService + }); + // setup the communication with license key server await licenseService.init(); diff --git a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts index ecb955025..955ec06ca 100644 --- a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts +++ b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts @@ -318,7 +318,8 @@ export const registerDeprecatedSecretFolderRouter = async (server: FastifyZodPro ...req.body, projectId: req.body.workspaceId, idOrName: req.params.folderIdOrName, - path + path, + forceDelete: true }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 20ed8d150..0a9cd8dd6 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -13,7 +13,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Renew access token", + description: "Renew machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.RENEW_ACCESS_TOKEN.accessToken) }), @@ -48,7 +48,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke access token", + description: "Revoke machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) }), diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index 4c444d704..3645a8bb6 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -21,7 +21,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Login with Alibaba Cloud Auth", + description: "Login with Alibaba Cloud Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(ALICLOUD_AUTH.LOGIN.identityId), Action: z.enum(["GetCallerIdentity"]).describe(ALICLOUD_AUTH.LOGIN.Action), @@ -108,7 +108,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Attach Alibaba Cloud Auth configuration onto identity", + description: "Attach Alibaba Cloud Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -200,7 +200,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Update Alibaba Cloud Auth configuration on identity", + description: "Update Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -292,7 +292,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Retrieve Alibaba Cloud Auth configuration on identity", + description: "Retrieve Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -340,7 +340,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Delete Alibaba Cloud Auth configuration on identity", + description: "Delete Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index fe7cf7d5b..59526899c 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -23,7 +23,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Login with AWS Auth", + description: "Login with AWS Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AWS_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), @@ -75,7 +75,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Attach AWS Auth configuration onto identity", + description: "Attach AWS Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -171,7 +171,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Update AWS Auth configuration on identity", + description: "Update AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Retrieve AWS Auth configuration on identity", + description: "Retrieve AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -303,7 +303,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Delete AWS Auth configuration on identity", + description: "Delete AWS Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index 9ef733a25..2649655bd 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Login with Azure Auth", + description: "Login with Azure Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AZURE_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Attach Azure Auth configuration onto identity", + description: "Attach Azure Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -165,7 +165,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Update Azure Auth configuration on identity", + description: "Update Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Retrieve Azure Auth configuration on identity", + description: "Retrieve Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Delete Azure Auth configuration on identity", + description: "Delete Azure Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 91e8038a7..d65c46613 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Login with GCP Auth", + description: "Login with GCP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(GCP_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Attach GCP Auth configuration onto identity", + description: "Attach GCP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -163,7 +163,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Update GCP Auth configuration on identity", + description: "Update GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -249,7 +249,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Retrieve GCP Auth configuration on identity", + description: "Retrieve GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -298,7 +298,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Delete GCP Auth configuration on identity", + description: "Delete GCP Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index ecffaebe1..2a882471d 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -96,7 +96,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Login with JWT Auth", + description: "Login with JWT Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -148,7 +148,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Attach JWT Auth configuration onto identity", + description: "Attach JWT Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -217,7 +217,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Update JWT Auth configuration on identity", + description: "Update JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -283,7 +283,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Retrieve JWT Auth configuration on identity", + description: "Retrieve JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -332,7 +332,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Delete JWT Auth configuration on identity", + description: "Delete JWT Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 6f0500c29..0794cf00d 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -41,7 +41,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Login with Kubernetes Auth", + description: "Login with Kubernetes Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(KUBERNETES_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -93,7 +93,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Attach Kubernetes Auth configuration onto identity", + description: "Attach Kubernetes Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -240,7 +240,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Update Kubernetes Auth configuration on identity", + description: "Update Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -383,7 +383,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Retrieve Kubernetes Auth configuration on identity", + description: "Retrieve Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -432,7 +432,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Delete Kubernetes Auth configuration on identity", + description: "Delete Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index ac384d216..caf5708e3 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -120,7 +120,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Login with LDAP Auth", + description: "Login with LDAP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), username: z.string().describe(LDAP_AUTH.LOGIN.username), @@ -198,7 +198,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Attach LDAP Auth configuration onto identity", + description: "Attach LDAP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -389,7 +389,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Update LDAP Auth configuration on identity", + description: "Update LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -510,7 +510,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Retrieve LDAP Auth configuration on identity", + description: "Retrieve LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -568,7 +568,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Delete LDAP Auth configuration on identity", + description: "Delete LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -621,7 +621,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Clear LDAP Auth Lockouts for identity", + description: "Clear LDAP Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index df19492a6..24d414286 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Login with OCI Auth", + description: "Login with OCI Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OCI_AUTH.LOGIN.identityId), userOcid: z.string().trim().describe(OCI_AUTH.LOGIN.userOcid), @@ -87,7 +87,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Attach OCI Auth configuration onto identity", + description: "Attach OCI Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -176,7 +176,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Update OCI Auth configuration on identity", + description: "Update OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -259,7 +259,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Retrieve OCI Auth configuration on identity", + description: "Retrieve OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -307,7 +307,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Delete OCI Auth configuration on identity", + description: "Delete OCI Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 147105aba..48fa64bf4 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Login with OIDC Auth", + description: "Login with OIDC Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OIDC_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -100,7 +100,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Attach OIDC Auth configuration onto identity", + description: "Attach OIDC Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -201,7 +201,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Update OIDC Auth configuration on identity", + description: "Update OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -300,7 +300,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Retrieve OIDC Auth configuration on identity", + description: "Retrieve OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -349,7 +349,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Delete OIDC Auth configuration on identity", + description: "Delete OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 65b9448c5..d6a42c4a2 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -34,7 +34,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Create identity", + description: "Create machine identity", security: [ { bearerAuth: [] @@ -109,7 +109,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Update identity", + description: "Update machine identity", security: [ { bearerAuth: [] @@ -173,7 +173,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Delete identity", + description: "Delete machine identity", security: [ { bearerAuth: [] @@ -222,7 +222,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Get an identity by id", + description: "Get a machine identity by id", security: [ { bearerAuth: [] @@ -280,7 +280,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "List identities", + description: "List machine identities", security: [ { bearerAuth: [] @@ -330,7 +330,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Search identities", + description: "Search machine identities", security: [ { bearerAuth: [] @@ -427,7 +427,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "List project memberships that identity with id is part of", + description: "List project memberships that machine identity with id is part of", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index 0503ed16f..d549160db 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Login with TLS Certificate Auth", + description: "Login with TLS Certificate Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) }), @@ -102,7 +102,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Attach TLS Certificate Auth configuration onto identity", + description: "Attach TLS Certificate Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -203,7 +203,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Update TLS Certificate Auth configuration on identity", + description: "Update TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Retrieve TLS Certificate Auth configuration on identity", + description: "Retrieve TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -354,7 +354,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Delete TLS Certificate Auth configuration on identity", + description: "Delete TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index 9a33d0651..9040d8909 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Attach Token Auth configuration onto identity", + description: "Attach Token Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -112,7 +112,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update Token Auth configuration on identity", + description: "Update Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -198,7 +198,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Retrieve Token Auth configuration on identity", + description: "Retrieve Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -247,7 +247,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Delete Token Auth configuration on identity", + description: "Delete Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -297,7 +297,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Create token for identity with Token Auth", + description: "Create token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -361,7 +361,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Get tokens for identity with Token Auth", + description: "Get tokens for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -416,7 +416,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update token for identity with Token Auth", + description: "Update token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -472,7 +472,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Revoke token for identity with Token Auth", + description: "Revoke token for machine identity with Token Auth", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 153e1e641..0443d35dd 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -32,7 +32,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Login with Universal Auth", + description: "Login with Universal Auth for machine identity", body: z.object({ clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) @@ -90,7 +90,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Attach Universal Auth configuration onto identity", + description: "Attach Universal Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -208,7 +208,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Update Universal Auth configuration on identity", + description: "Update Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -331,7 +331,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Retrieve Universal Auth configuration on identity", + description: "Retrieve Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -380,7 +380,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Delete Universal Auth configuration on identity", + description: "Delete Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -429,7 +429,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Create Universal Auth Client Secret for identity", + description: "Create Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -487,7 +487,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "List Universal Auth Client Secrets for identity", + description: "List Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -537,7 +537,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Get Universal Auth Client Secret for identity", + description: "Get Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -567,7 +567,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, orgId: clientSecretData.orgId, event: { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID, metadata: { identityId: clientSecretData.identityId, clientSecretId: clientSecretData.id @@ -589,7 +589,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke Universal Auth Client Secrets for identity", + description: "Revoke Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -641,7 +641,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Clear Universal Auth Lockouts for identity", + description: "Clear Universal Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v2/secret-folder-router.ts b/backend/src/server/routes/v2/secret-folder-router.ts index 0bf062452..251ad8e85 100644 --- a/backend/src/server/routes/v2/secret-folder-router.ts +++ b/backend/src/server/routes/v2/secret-folder-router.ts @@ -263,7 +263,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path) + .describe(FOLDERS.DELETE.path), + forceDelete: z.boolean().optional().default(false).describe(FOLDERS.DELETE.forceDelete) }), response: { 200: z.object({ @@ -279,7 +280,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - idOrName: req.params.folderIdOrName + idOrName: req.params.folderIdOrName, + forceDelete: req.body.forceDelete }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index a3b744485..f0737e01d 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ExternalMigrationProviders, + VaultImportStatus, VaultMappingType } from "@app/services/external-migration/external-migration-types"; @@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider return { enabled }; } }); + + server.route({ + method: "GET", + url: "/vault/configs", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + configs: z + .object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const configs = await server.services.migration.getVaultExternalMigrationConfigs({ + actor: req.permission + }); + + return { configs }; + } + }); + + server.route({ + method: "POST", + url: "/vault/configs", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.createVaultExternalMigration({ + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "PUT", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.updateVaultExternalMigration({ + id: req.params.id, + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "DELETE", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.deleteVaultExternalMigration({ + id: req.params.id, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "GET", + url: "/vault/namespaces", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + namespaces: z.array(z.object({ id: z.string(), name: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const namespaces = await server.services.migration.getVaultNamespaces({ + actor: req.permission + }); + + return { namespaces }; + } + }); + + server.route({ + method: "GET", + url: "/vault/policies", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + policies: z.array(z.object({ name: z.string(), rules: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policies = await server.services.migration.getVaultPolicies({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { policies }; + } + }); + + server.route({ + method: "GET", + url: "/vault/mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultMounts({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { mounts }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + authType: z.string().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultAuthMounts({ + actor: req.permission, + namespace: req.query.namespace, + authType: req.query.authType + }); + + return { mounts }; + } + }); + + server.route({ + method: "POST", + url: "/vault/import-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectId: z.string(), + environment: z.string(), + secretPath: z.string(), + vaultNamespace: z.string(), + vaultSecretPath: z.string() + }), + response: { + 200: z.object({ + status: z.nativeEnum(VaultImportStatus) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.migration.importVaultSecrets({ + actor: req.permission, + auditLogInfo: req.auditLogInfo, + ...req.body + }); + + return result; + } + }); + + server.route({ + method: "GET", + url: "/vault/secret-paths", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + secretPaths: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretPaths = await server.services.migration.getVaultSecretPaths({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { secretPaths }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-roles/kubernetes", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + bound_service_account_names: z.array(z.string()), + bound_service_account_namespaces: z.array(z.string()), + token_ttl: z.number().optional(), + token_max_ttl: z.number().optional(), + token_policies: z.array(z.string()).optional(), + token_bound_cidrs: z.array(z.string()).optional(), + token_explicit_max_ttl: z.number().optional(), + token_no_default_policy: z.boolean().optional(), + token_num_uses: z.number().optional(), + token_period: z.number().optional(), + token_type: z.string().optional(), + audience: z.string().optional(), + alias_name_source: z.string().optional(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().optional(), + issuer: z.string().optional(), + disable_iss_validation: z.boolean().optional(), + disable_local_ca_jwt: z.boolean().optional() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesAuthRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); }; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts index a1e2c8f09..ce0e00f4a 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts @@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod { AccessToken = "access-token", AppRole = "app-role" } + +export enum HCVaultAuthType { + Kubernetes = "kubernetes" +} diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 3a79e2f8e..38f97700c 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -12,8 +12,56 @@ import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; +import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums"; +import { + THCVaultAuthMount, + THCVaultAuthMountResponse, + THCVaultConnection, + THCVaultConnectionConfig, + THCVaultKubernetesAuthConfig, + THCVaultKubernetesAuthRole, + THCVaultKubernetesAuthRoleWithConfig, + THCVaultMount, + THCVaultMountResponse +} from "./hc-vault-connection-types"; + +// Concurrency limit for HC Vault API requests to avoid rate limiting +const HC_VAULT_CONCURRENCY_LIMIT = 20; + +/** + * Creates a concurrency limiter that restricts the number of concurrent async operations + * @param limit - Maximum number of concurrent operations + * @returns A function that takes an async function and executes it with concurrency control + */ +const createConcurrencyLimiter = (limit: number) => { + let activeCount = 0; + const queue: Array<() => void> = []; + + const next = () => { + activeCount -= 1; + if (queue.length > 0) { + const resolve = queue.shift(); + resolve?.(); + } + }; + + return async (fn: () => Promise): Promise => { + // If we're at the limit, wait in queue + if (activeCount >= limit) { + await new Promise((resolve) => { + queue.push(resolve); + }); + } + + activeCount += 1; + + try { + return await fn(); + } finally { + next(); + } + }; +}; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -181,30 +229,573 @@ export const validateHCVaultConnectionCredentials = async ( } }; -export const listHCVaultMounts = async ( +export const listHCVaultPolicies = async ( + namespace: string, connection: THCVaultConnection, gatewayService: Pick ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); + try { + const { data: listData } = await requestWithHCVaultGateway<{ + data: { + policies: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const policyNames = listData.data.policies || []; + + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const policies = await Promise.all( + policyNames.map((policyName) => + limiter(async () => { + try { + const { data: policyData } = await requestWithHCVaultGateway<{ + data: { + name: string; + rules: string; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy/${policyName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return { + name: policyData.data.name, + rules: policyData.data.rules + }; + } catch (error: unknown) { + logger.error(error, `Unable to fetch policy details for ${policyName}`); + return { + name: policyName, + rules: "" + }; + } + }) + ) + ); + + return policies; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault policies"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list policies: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list policies from HashiCorp Vault" + }); + } +}; + +export const listHCVaultNamespaces = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + 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 => { + try { + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + key_info?: { + [key: string]: { + id: string; + path: string; + custom_metadata?: Record; + }; + }; + }; + }>(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 + ): Promise => { + 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, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const targetNamespace = namespace || connection.credentials.namespace; + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { url: `${instanceUrl}/v1/sys/mounts`, method: "GET", headers: { "X-Vault-Token": accessToken, - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) } }); - const mounts: string[] = []; + const mounts: THCVaultMount[] = []; - // Filter for "kv" version 2 type only Object.entries(data.data).forEach(([path, mount]) => { - if (mount.type === "kv" && mount.options?.version === "2") { - mounts.push(path); - } + mounts.push({ + path, + type: mount.type, + version: mount.options?.version + }); }); return mounts; }; + +export const listHCVaultSecretPaths = async ( + namespace: string, + connection: THCVaultConnection, + gatewayService: Pick, + filterMountPath?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise => { + 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, + currentPath: string = "" + ): Promise => { + 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 +) => { + 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; // 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; // 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 +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + const { data } = await requestWithHCVaultGateway(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 +): Promise => { + 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" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index 589c7c1bd..037964da4 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -21,7 +21,8 @@ export const hcVaultConnectionService = ( try { const mounts = await listHCVaultMounts(appConnection, gatewayService); - return mounts; + // Filter for KV version 2 mounts only and extract just the paths + return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path); } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); return []; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 6f254eda0..d25dbc9e6 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -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 | 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; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 14885b1d6..4192ffcd5 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,9 +1,33 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { + AuditLogInfo, + EventType, + SecretApprovalEvent, + TAuditLogServiceFactory +} from "@app/ee/services/audit-log/audit-log-types"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "../app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { + getHCVaultAuthMounts, + getHCVaultKubernetesAuthRoles, + getHCVaultSecretsForPath, + HCVaultAuthType, + listHCVaultMounts, + listHCVaultPolicies, + listHCVaultSecretPaths, + THCVaultConnection +} from "../app-connection/hc-vault"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { SecretProtectionType } from "../secret/secret-types"; import { TUserDALFactory } from "../user/user-dal"; import { decryptEnvKeyDataFn, @@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { ExternalMigrationProviders, ExternalPlatforms, + TCreateVaultExternalMigrationDTO, + TDeleteVaultExternalMigrationDTO, THasCustomVaultMigrationDTO, TImportEnvKeyDataDTO, - TImportVaultDataDTO + TImportVaultDataDTO, + TUpdateVaultExternalMigrationDTO, + VaultImportStatus } from "./external-migration-types"; +import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; + secretService: TSecretServiceFactory; + auditLogService: Pick; externalMigrationQueue: TExternalMigrationQueueFactory; + appConnectionService: Pick; + vaultExternalMigrationConfigDAL: Pick< + TVaultExternalMigrationConfigDALFactory, + "create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById" + >; userDAL: Pick; gatewayService: Pick; + kmsService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({ permissionService, externalMigrationQueue, userDAL, - gatewayService + gatewayService, + secretService, + auditLogService, + appConnectionService, + vaultExternalMigrationConfigDAL, + kmsService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ decryptionKey, @@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({ return actorOrgId in vaultMigrationTransformMappings; }; + const validateVaultExternalMigrationConnection = async ({ + connection, + namespace + }: { + connection: THCVaultConnection; + namespace: string; + }) => { + // Allow root namespace access when no namespace is configured on the connection + const isRootAccess = namespace === "root" || namespace === "/"; + const hasNoNamespace = connection.credentials.namespace === undefined; + + if (hasNoNamespace && isRootAccess) { + // Skip validation for root access with no configured namespace + } else if (connection.credentials.namespace !== namespace) { + throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" }); + } + + try { + await listHCVaultPolicies(namespace, connection, gatewayService); + await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); + + const mounts = await listHCVaultMounts(connection, gatewayService); + const sampleKvMount = mounts.find((mount) => mount.type === "kv"); + if (sampleKvMount) { + await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path); + } + } catch (error) { + throw new BadRequestError({ + message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" }); + } + + const connection = await appConnectionService.connectAppConnectionById( + 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( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + } + + const config = await vaultExternalMigrationConfigDAL.updateById(id, { + namespace, + connectionId + }); + + return config; + }; + + const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" }); + } + + const configs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + return configs; + }; + + const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" }); + } + + // Get all configured namespaces for this org + const vaultConfigs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + // Return the configured namespaces as an array of objects with id and name + // where both id and name are the namespace path + const namespaces = vaultConfigs.map((config) => ({ + id: config.namespace, + name: config.namespace + })); + + return namespaces; + }; + + const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault policies" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const policies = await listHCVaultPolicies(namespace, connection, gatewayService); + return policies; + }; + + const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + return mounts; + }; + + const getVaultSecretPaths = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath); + + return secretPaths; + }; + + const importVaultSecrets = async ({ + actor, + projectId, + environment, + secretPath, + vaultNamespace, + vaultSecretPath, + auditLogInfo + }: { + actor: OrgServiceActor; + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; + auditLogInfo: AuditLogInfo; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace: vaultNamespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService); + + try { + const secretOperation = await secretService.createManySecretsRaw({ + actorId: actor.id, + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + secretPath, + environment, + projectId, + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue + })) + }); + + if (secretOperation.type === SecretProtectionType.Approval) { + await auditLogService.createAuditLog({ + projectId, + ...auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({ + secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); + + return { status: VaultImportStatus.ApprovalRequired }; + } + + return { status: VaultImportStatus.Imported }; + } catch (error) { + throw new BadRequestError({ + message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" }); + } + + const config = await vaultExternalMigrationConfigDAL.findById(id); + + if (!config) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (config.orgId !== actor.orgId) { + throw new ForbiddenRequestError({ message: "Config does not belong to this organization" }); + } + + const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id); + + return deletedConfig; + }; + + const getVaultAuthMounts = async ({ + actor, + namespace, + authType + }: { + actor: OrgServiceActor; + namespace: string; + authType?: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService); + + return authMounts; + }; + + const getVaultKubernetesAuthRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + // Get roles for the specified mount path only + const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService); + + return roles; + }; + return { importEnvKeyData, importVaultData, - hasCustomVaultMigration + hasCustomVaultMigration, + createVaultExternalMigration, + getVaultExternalMigrationConfigs, + updateVaultExternalMigration, + deleteVaultExternalMigration, + getVaultNamespaces, + getVaultPolicies, + getVaultMounts, + getVaultAuthMounts, + getVaultSecretPaths, + importVaultSecrets, + getVaultKubernetesAuthRoles }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 804444172..49e5bfa88 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -1,4 +1,4 @@ -import { TOrgPermission } from "@app/lib/types"; +import { OrgServiceActor, TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -121,3 +121,26 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TCreateVaultExternalMigrationDTO = { + namespace: string; + connectionId: string; + actor: OrgServiceActor; +}; + +export type TUpdateVaultExternalMigrationDTO = { + id: string; + namespace: string; + connectionId: string | null; + actor: OrgServiceActor; +}; + +export type TDeleteVaultExternalMigrationDTO = { + id: string; + actor: OrgServiceActor; +}; diff --git a/backend/src/services/external-migration/vault-external-migration-config-dal.ts b/backend/src/services/external-migration/vault-external-migration-config-dal.ts new file mode 100644 index 000000000..34ff8757a --- /dev/null +++ b/backend/src/services/external-migration/vault-external-migration-config-dal.ts @@ -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; + +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 }; +}; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index eaf0adbde..11badd7a5 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -99,5 +99,5 @@ export type TImportKeyMaterialDTO = { projectId: string; orgId: string; keyUsage: KmsKeyUsage; - kmipMetadata?: Record; + kmipMetadata?: Record | null; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 02314ced2..7bacd9468 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -5,6 +5,7 @@ import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { ActionProjectType, TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TDynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -48,7 +49,11 @@ type TSecretFolderServiceFactoryDep = { folderCommitService: Pick; projectDAL: Pick; secretApprovalPolicyService: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "findByFolderIds" | "invalidateSecretCacheByProjectId" | "findOne" + >; + dynamicSecretDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -62,7 +67,8 @@ export const secretFolderServiceFactory = ({ folderCommitService, projectDAL, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -645,7 +651,8 @@ export const secretFolderServiceFactory = ({ actorAuthMethod, environment, path: secretPath, - idOrName + idOrName, + forceDelete = false }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -697,6 +704,22 @@ export const secretFolderServiceFactory = ({ throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); } + // Check if folder contains resources (secrets, dynamic secrets, subfolders) + if (!forceDelete) { + const error = new BadRequestError({ + message: `Cannot delete folder "${folderToDelete.name}" because it contains resources. Use forceDelete=true to delete it forcefully.`, + name: "deleteFolder" + }); + const secretV2 = await secretV2BridgeDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (secretV2) throw error; + + const dynamicSecret = await dynamicSecretDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (dynamicSecret) throw error; + + const subfolder = await folderDAL.findOne({ parentId: folderToDelete.id }).catch(() => null); + if (subfolder) throw error; + } + const [doc] = await folderDAL.delete( { envId: env.id, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index eed815da5..da8be52a0 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -37,6 +37,7 @@ export type TDeleteFolderDTO = { environment: string; path: string; idOrName: string; + forceDelete?: boolean; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/docs/documentation/platform/external-migrations/overview.mdx b/docs/documentation/platform/external-migrations/overview.mdx index cf6c5e5a1..d8e85fe91 100644 --- a/docs/documentation/platform/external-migrations/overview.mdx +++ b/docs/documentation/platform/external-migrations/overview.mdx @@ -1,16 +1,22 @@ --- title: "External Migrations" sidebarTitle: "Overview" -description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical." +description: "Learn how to migrate resources from third-party secrets management platforms to Infisical." --- ## Overview -Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform. +Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform. + +Infisical offers two types of migration approaches: + +- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed. + +- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical. ## Supported Platforms - [EnvKey](./envkey) - [Vault](./vault) -We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). \ No newline at end of file +We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index 8254e104f..e267cc5e9 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -1,40 +1,239 @@ --- title: "Migrating from Vault to Infisical" sidebarTitle: "Vault" -description: "Learn how to migrate secrets from Vault to Infisical." +description: "Learn how to migrate resources from Vault to Infisical." --- -## Migrating from Vault +Infisical provides two approaches for migrating from HashiCorp Vault. -Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. +### Which approach should I use? -Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +**Choose In-Platform Migration Tooling if you want to:** +- Migrate specific secrets, not everything at once +- Import secrets into existing Infisical projects +- Translate Vault policies to Infisical access controls +- Import Kubernetes authentication configurations +- Have more control over the migration process -### Prerequisites +**Choose Bulk Data Import if you want to:** -- A Vault instance with the KV secret engine enabled. -- An access token to your Vault instance. - - -### Project Mapping - -When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects. - -There are two options for project mapping: - -- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines. -- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine. +- Migrate all secrets from Vault in one go +- Automatically create new Infisical projects from your Vault structure +- Perform a one-time migration when moving entirely from Vault to Infisical +## In-Platform Migration Tooling +This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. +### Step 1: Set Up Your Vault Connection - 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. + + ```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"] + } + ``` + + + Save this policy in Vault with the name `infisical-in-platform-migration`. + + + + + 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. + + + + + Navigate to **Organization Settings > External Migrations** in Infisical. + + Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**. + + ![In-Platform Migration Tooling](/images/platform/external-migrations/vault-in-platform/external-migration-overview.png) + + Configure your namespace: + + ![Namespace Configuration](/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png) + + - **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. + + + You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from. + + + + + +### 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"** + + ![Import Vault Secrets](/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png) + +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"** + + ![Load Kubernetes Auth from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png) + +4. Select your Vault namespace and the Kubernetes role +5. Click **"Load"** + + ![Kubernetes Auth Form Populated](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png) + +The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration. + + + Sensitive values like service account JWTs cannot be retrieved from Vault and + must be manually provided in the form after importing the configuration. + + +#### 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. + + + 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. + + +**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"** + + ![Import Vault Policy Button](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png) + +3. Select your Vault namespace +4. Either choose an existing policy from the dropdown or paste your own HCL policy + + ![Translate Vault Policy Modal](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png) + +5. Review the automatically translated Infisical permissions +6. Make any adjustments and save + + + **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. + + +--- + +## 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: + + + 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`) + + + + 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`) + + +### How to Perform a Bulk Import + + + + In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access. + + + ```hcl # Allow listing secret engines/mounts path "sys/mounts" { capabilities = ["read", "list"] @@ -63,65 +262,54 @@ There are two options for project mapping: capabilities = ["read", "list"] } ``` + - Save this policy with the name `infisical-migration`. + Save this policy in Vault with the name `infisical-bulk-migration`. - You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step. + Use the Vault CLI to generate an access token: ```bash - vault token create --policy="infisical-migration" + vault token create --policy="infisical-bulk-migration" ``` - After generating the token, you should see the following output: + Copy the `token` value from the output - you'll need it in the next step. - ```t - $ vault token create --policy="infisical-migration" - - Key Value - --- ----- - token - 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. - - Open the Infisical dashboard and go to Organization Settings > External Migrations. + + In Infisical, navigate to **Organization Settings > External Migrations**. ![Infisical Organization settings](/images/platform/external-migrations/infisical-external-migration-dashboard.png) + + Under the "Bulk Data Import" section, click **"+ Import"**. + - - Select the Vault platform and click on Next. + + Select **HashiCorp Vault** as the migration source and click **Next**. ![Select Vault platform](/images/platform/external-migrations/infisical-import-vault-modal.png) + - - Enter the Vault access token that you generated in the previous step and click Import data. + + Fill in your Vault connection details: ![Configure Vault migration](/images/platform/external-migrations/infisical-import-vault.png) - - `Vault URL`: The URL of your Vault instance. - - `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance. - - `Vault Access Token`: The access token that you generated in the previous step. + - **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`) + - **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces + - **Vault Access Token**: The token you generated in step 2 + - **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping)) - - `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section. + Click **"Import Data"** to start the migration. - Click on Import data to start the migration. + + The import runs in the background and may take several minutes. You'll receive an email when it completes. + - - - 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. - \ No newline at end of file diff --git a/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png new file mode 100644 index 000000000..3f2be6217 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png new file mode 100644 index 000000000..230393be6 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png new file mode 100644 index 000000000..e3c41d187 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png new file mode 100644 index 000000000..55185db37 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png new file mode 100644 index 000000000..828b82544 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png new file mode 100644 index 000000000..34955b03f Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png new file mode 100644 index 000000000..a165fff77 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png differ diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 970e0592d..33bc2107d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -43,6 +43,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID]: + "Get universal auth client secret by id", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b10fcb60a..ea684a4bb 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -48,6 +48,7 @@ export enum EventType { REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 1bb321fa2..fc15b5064 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -318,6 +318,14 @@ interface GetIdentityUniversalAuthClientSecretsEvent { }; } +interface GetIdentityUniversalAuthClientSecretByIdEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface RevokeIdentityUniversalAuthClientSecretEvent { type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -906,6 +914,7 @@ export type Event = | GetIdentityUniversalAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent + | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index 0c2adeab0..177955438 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1,2 +1,3 @@ export * from "./mutations"; export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index b2694b458..00d3148ef 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -1,8 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { secretKeys } from "@app/hooks/api/secrets/queries"; import { projectKeys } from "../projects"; +import { externalMigrationQueryKeys } from "./queries"; +import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types"; export const useImportEnvKey = () => { const queryClient = useQueryClient(); @@ -65,3 +69,97 @@ export const useImportVault = () => { } }); }; + +export const useImportVaultSecrets = () => { + const queryClient = useQueryClient(); + + return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ status: VaultImportStatus }>( + "/api/v3/external-migration/vault/import-secrets", + dto + ); + return data; + }, + onSuccess: (_, { projectId, environment, secretPath }) => { + queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); + queryClient.invalidateQueries({ + queryKey: secretKeys.getProjectSecret({ + projectId, + environment, + secretPath + }) + }); + } + }); +}; + +export const useCreateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { connectionId: string; namespace: string } + >({ + mutationFn: async ({ connectionId, namespace }) => { + const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>( + "/api/v3/external-migration/vault/configs", + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useUpdateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { id: string; connectionId: string; namespace: string } + >({ + mutationFn: async ({ id, connectionId, namespace }) => { + const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>( + `/api/v3/external-migration/vault/configs/${id}`, + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useDeleteVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation({ + 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() + }); + } + }); +}; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e96533b09..e4ce6824d 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ExternalMigrationProviders } from "./types"; +import { + ExternalMigrationProviders, + TVaultExternalMigrationConfig, + VaultKubernetesAuthRole +} from "./types"; -const externalMigrationQueryKeys = { +export const externalMigrationQueryKeys = { customMigrationAvailable: (provider: ExternalMigrationProviders) => [ "custom-migration-available", provider + ], + vaultConfigs: () => ["vault-external-migration-configs"], + vaultNamespaces: () => ["vault-namespaces"], + vaultPolicies: (namespace?: string) => ["vault-policies", namespace], + vaultMounts: (namespace?: string) => ["vault-mounts", namespace], + vaultAuthMounts: (namespace?: string, authType?: string) => [ + "vault-auth-mounts", + namespace, + authType + ], + vaultSecretPaths: (namespace?: string, mountPath?: string) => [ + "vault-secret-paths", + namespace, + mountPath + ], + vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-auth-roles", + namespace, + mountPath ] }; @@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid ) }); }; + +export const useGetVaultExternalMigrationConfigs = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultConfigs(), + queryFn: async () => { + const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>( + "/api/v3/external-migration/vault/configs" + ); + return data.configs; + } + }); +}; + +export const useGetVaultNamespaces = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultNamespaces(), + queryFn: async () => { + const { data } = await apiRequest.get<{ + namespaces: Array<{ id: string; name: string }>; + }>("/api/v3/external-migration/vault/namespaces"); + return data.namespaces; + } + }); +}; + +export const useGetVaultPolicies = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultPolicies(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + policies: Array<{ name: string; rules: string }>; + }>("/api/v3/external-migration/vault/policies", { + params: { + namespace + } + }); + + return data.policies; + }, + enabled + }); +}; + +export const useGetVaultMounts = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultMounts(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string; version: string | null }>; + }>("/api/v3/external-migration/vault/mounts", { + params: { + namespace + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + secretPaths: string[]; + }>("/api/v3/external-migration/vault/secret-paths", { + params: { + namespace, + mountPath + } + }); + + return data.secretPaths; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; + +export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string }>; + }>("/api/v3/external-migration/vault/auth-mounts", { + params: { + namespace, + ...(authType && { authType }) + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultKubernetesAuthRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + roles: VaultKubernetesAuthRole[]; + }>("/api/v3/external-migration/vault/auth-roles/kubernetes", { + params: { + namespace, + mountPath + } + }); + + return data.roles; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index 945f18d8e..f4303ea26 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -2,3 +2,50 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TVaultExternalMigrationConfig = { + id: string; + orgId: string; + namespace: string; + connectionId: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TImportVaultSecretsDTO = { + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; +}; + +export type VaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; + mountPath: string; + config: { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; + }; +}; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 3e752eed3..694a33d13 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -218,12 +218,13 @@ export const useDeleteFolder = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ path = "/", folderId, environment, projectId }) => { + mutationFn: async ({ path = "/", folderId, environment, projectId, forceDelete = true }) => { const { data } = await apiRequest.delete(`/api/v2/folders/${folderId}`, { data: { environment, projectId, - path + path, + forceDelete } }); return data; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index e9ee2ae5d..f3e3cecaf 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -59,6 +59,7 @@ export type TDeleteFolderDTO = { environment: string; folderId: string; path?: string; + forceDelete?: boolean; }; export type TUpdateFolderBatchDTO = { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index d2926e024..3cfe1c355 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -37,9 +37,12 @@ import { IdentityKubernetesAuthTokenReviewMode, IdentityTrustedIp } from "@app/hooks/api/identities/types"; -import { UsePopUpState } from "@app/hooks/usePopUp"; +import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries"; +import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types"; +import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; +import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal"; const schema = z .object({ @@ -121,6 +124,12 @@ export const IdentityKubernetesAuthForm = ({ enabled: isUpdate }); + const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([ + "importFromVault" + ] as const); + const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); + const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const { control, handleSubmit, @@ -192,6 +201,99 @@ export const IdentityKubernetesAuthForm = ({ } }, [data]); + const handleImportFromVault = (role: VaultKubernetesAuthRole) => { + try { + setValue("kubernetesHost", role.config.kubernetes_host, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + }); + + if (role.bound_service_account_names?.length > 0) { + // In Vault, "*" means allow all; in Infisical, empty field means allow any + const allowedNames = role.bound_service_account_names.includes("*") + ? "" + : role.bound_service_account_names.join(", "); + setValue("allowedNames", allowedNames, { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.bound_service_account_namespaces?.length > 0) { + // In Vault, "*" means allow all; in Infisical, empty field means allow any + const allowedNamespaces = role.bound_service_account_namespaces.includes("*") + ? "" + : role.bound_service_account_namespaces.join(", "); + setValue("allowedNamespaces", allowedNamespaces, { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.token_ttl !== undefined) { + setValue("accessTokenTTL", String(role.token_ttl), { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.token_max_ttl !== undefined) { + setValue("accessTokenMaxTTL", String(role.token_max_ttl), { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.token_num_uses !== undefined) { + setValue("accessTokenNumUsesLimit", String(role.token_num_uses), { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.audience) { + setValue("allowedAudience", role.audience, { + shouldDirty: true, + shouldTouch: true + }); + } + + if (role.config.kubernetes_ca_cert) { + setValue("caCert", role.config.kubernetes_ca_cert, { + shouldDirty: true, + shouldTouch: true + }); + } + + if ( + subscription?.ipAllowlisting && + role.token_bound_cidrs && + role.token_bound_cidrs.length > 0 + ) { + setValue( + "accessTokenTrustedIps", + role.token_bound_cidrs.map((cidr) => ({ ipAddress: cidr })), + { + shouldDirty: true, + shouldTouch: true + } + ); + } + + createNotification({ + type: "info", + text: `Successfully prefilled values from Kubernetes auth role: ${role.name}` + }); + } catch (err) { + console.error("Import error:", err); + createNotification({ + type: "error", + text: "Failed to import Kubernetes auth configuration" + }); + } + }; + const onFormSubmit = async ({ kubernetesHost, tokenReviewerJwt, @@ -301,6 +403,28 @@ export const IdentityKubernetesAuthForm = ({ Advanced + {hasVaultConnection && !isUpdate && ( +
+
+ + Load values from HashiCorp Vault +
+ +
+ )}
)} @@ -425,7 +550,7 @@ export const IdentityKubernetesAuthForm = ({ errorText={error?.message} tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding." > - + )} /> @@ -441,7 +566,12 @@ export const IdentityKubernetesAuthForm = ({ errorText={error?.message} tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical." > - + )} /> @@ -456,7 +586,11 @@ export const IdentityKubernetesAuthForm = ({ tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account." errorText={error?.message} > - + )} /> @@ -628,6 +762,11 @@ export const IdentityKubernetesAuthForm = ({ Cancel
+ handleImportPopUpToggle("importFromVault", isOpen)} + onImport={handleImportFromVault} + /> ); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx new file mode 100644 index 000000000..7491b51d6 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx @@ -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(null); + const [selectedMountPath, setSelectedMountPath] = useState(null); + const [selectedRole, setSelectedRole] = useState(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 ( + <> + + <> + 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" + /> +

+ Select the Vault namespace to fetch available auth mounts +

+ +
+ + + <> + 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" + /> +

+ Choose a Kubernetes auth engine to filter available roles +

+ +
+ + + <> + { + 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" + /> +

+ Select the Kubernetes role to load configuration from +

+ +
+ +
+ + + + +
+ + ); +}; + +export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => { + return ( + + + onOpenChange(false)} onImport={onImport} /> + + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx index 4857e8fe3..34f1937b5 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx @@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal"; +import { VaultConnectionSection } from "./components/VaultConnectionSection"; export const ExternalMigrationsTab = () => { const { hasOrgRole } = useOrgPermission(); @@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const); return ( -
-
-
-

Import from external source

+
+ {/* In-Platform Migration Tooling Section */} +
+
+

In-Platform Migration Tooling

+

+ Configure platform connections to enable migration features throughout Infisical, such + as importing policies and resources directly within the UI. +

+
+ +
- + {/* Bulk Data Import Section */} +
+
+

Bulk Data Import

+

+ Perform one-time bulk imports of data from external platforms. +

- -
-

Import data from another platform to Infisical.

+
+
+
+

+ Import from external source +

+ +
+ + Docs + +
+
+
+

+ Import data from another platform to Infisical. +

+
- handlePopUpToggle("selectImportPlatform", state)} - /> + +
+ + handlePopUpToggle("selectImportPlatform", state)} + /> +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx new file mode 100644 index 000000000..c91e9c24c --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx @@ -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(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [configToDelete, setConfigToDelete] = useState(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 ( +
+
+
+ HashiCorp Vault logo +
+

HashiCorp Vault

+

+ Enable in-platform migration tooling for policy imports, auth methods, and secret + engine migrations +

+
+
+ +
+ + + + + + + + + + + {isLoadingConfigs && ( + + )} + {!isLoadingConfigs && configs.length === 0 && ( + + + + )} + {!isLoadingConfigs && + configs.map((config) => ( + + + + + + ))} + +
NamespaceConnection +
+ +

+ Add a namespace configuration to enable in-platform migration features. +

+
+
{config.namespace}{getConnectionName(config.connectionId)} +
+ + +
+
+
+ +

+ Configure namespace-specific connections to enable in-platform migration features. Manage + connections in the{" "} + + App Connections + {" "} + section. +

+ + { + setIsModalOpen(open); + if (!open) setSelectedConfig(null); + }} + editConfig={selectedConfig || undefined} + /> + + { + setIsDeleteModalOpen(open); + if (!open) setConfigToDelete(null); + }} + deleteKey="confirm" + onDeleteApproved={handleDeleteConfirm} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx new file mode 100644 index 000000000..62fb78b48 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx @@ -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; + +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({ + 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 ( + + +
+ ( + + + + )} + /> + + { + const selectedConnection = vaultConnections.find((conn) => conn.id === field.value); + + return ( + + { + 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} + /> + + ); + }} + /> + +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx index 37f6b3868..16ceb1863 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx @@ -6,12 +6,17 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, - IconButton + IconButton, + Tooltip } from "@app/components/v2"; +import { useOrgPermission } from "@app/context"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; +import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration"; import { ProjectType } from "@app/hooks/api/projects/types"; import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal"; import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal"; +import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal"; type Props = { isDisabled?: boolean; @@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addPolicy", "addPolicyOptions", - "applyTemplate" + "applyTemplate", + "importFromVault" ] as const); + const { hasOrgRole } = useOrgPermission(); + const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); + const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); + const isVaultImportDisabled = isDisabled || !isOrgAdmin; + return (
+ {hasVaultConnection && ( + + + + )}
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { isOpen={popUp.applyTemplate.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)} /> + handlePopUpToggle("importFromVault", isOpen)} + />
); }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx new file mode 100644 index 000000000..d126791f0 --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx @@ -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(); + const [selectedNamespace, setSelectedNamespace] = useState(null); + const [selectedPolicy, setSelectedPolicy] = useState(null); + const [hclPolicy, setHclPolicy] = useState(""); + 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 ( + <> +
+
+ +
+
+ How Policy Translation Works +
+
+

+ Policies are translated by identifying KV secret engine mounts and parsing path + structures to extract environments and secret paths. +

+

+ Key assumptions: The first path segment after the mount is treated + as the environment (e.g., secret/data/prod/app → + env: prod, path:{" "} + /app). Vault capabilities and wildcards are + automatically mapped to equivalent Infisical permissions and glob patterns. +

+
+
+
+
+ + + <> + 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" + /> +

+ Select the Vault namespace to fetch policies and mount information +

+ +
+ + + <> + 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" + /> +

+ Select a policy to auto-populate the HCL editor below, or skip to paste your own +

+ +
+ + + <> +