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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2d0de59a2..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." } }; @@ -2355,6 +2356,9 @@ export const AppConnections = { sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", sslCertificate: "The SSL certificate (PEM format) to use for secure connection." + }, + LARAVEL_FORGE: { + apiToken: "The API token used to authenticate with Laravel Forge." } } }; @@ -2507,6 +2511,14 @@ export const SecretSyncs = { branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." }, + LARAVEL_FORGE: { + orgSlug: "The slug of the Laravel Forge org to sync secrets to.", + orgName: "The name of the Laravel Forge org to sync secrets to.", + serverId: "The ID of the Laravel Forge server to sync secrets to.", + serverName: "The name of the Laravel Forge server to sync secrets to.", + siteId: "The ID of the Laravel Forge site to sync secrets to.", + siteName: "The name of the Laravel Forge site to sync secrets to." + }, WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 448564704..eab6b0e30 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"; @@ -535,6 +536,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); @@ -1345,7 +1348,8 @@ export const registerRoutes = async ( projectDAL, folderCommitService, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }); const secretImportService = secretImportServiceFactory({ @@ -1882,13 +1886,6 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, @@ -2211,6 +2208,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/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index e5549f9fe..c799ef0f0 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -77,6 +77,10 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { + LaravelForgeConnectionListItemSchema, + SanitizedLaravelForgeConnectionSchema +} from "@app/services/app-connection/laravel-forge"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; @@ -158,7 +162,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, - ...SanitizedRedisConnectionSchema.options + ...SanitizedRedisConnectionSchema.options, + ...SanitizedLaravelForgeConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -200,7 +205,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, - RedisConnectionListItemSchema + RedisConnectionListItemSchema, + LaravelForgeConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 11d9ce5e6..2e3da4420 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -24,6 +24,7 @@ import { registerGitLabConnectionRouter } from "./gitlab-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerLaravelForgeConnectionRouter } from "./laravel-forge-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; @@ -71,6 +72,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.LaravelForge, + server, + sanitizedResponseSchema: SanitizedLaravelForgeConnectionSchema, + createSchema: CreateLaravelForgeConnectionSchema, + updateSchema: UpdateLaravelForgeConnectionSchema + }); + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const organizations = await server.services.appConnection.laravelForge.listOrganizations( + connectionId, + req.permission + ); + + return organizations; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/servers`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug } = req.query; + const servers = await server.services.appConnection.laravelForge.listServers( + connectionId, + req.permission, + organizationSlug + ); + + return servers; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string(), + serverId: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug, serverId } = req.query; + const sites = await server.services.appConnection.laravelForge.listSites( + connectionId, + req.permission, + organizationSlug, + serverId + ); + + return sites; + } + }); +}; 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/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index fed56277e..e778dbd7c 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; @@ -63,5 +64,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.LaravelForge, + server, + responseSchema: LaravelForgeSyncSchema, + createSchema: CreateLaravelForgeSyncSchema, + updateSchema: UpdateLaravelForgeSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 71e8b2cca..1bfa32eeb 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; @@ -84,7 +85,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, - BitbucketSyncSchema + BitbucketSyncSchema, + LaravelForgeSyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -117,7 +119,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, - BitbucketSyncListItemSchema + BitbucketSyncListItemSchema, + LaravelForgeSyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { 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/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 996cd872a..54b70c7d3 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -37,7 +37,8 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 73abef78d..464f718ce 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -103,6 +103,11 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { + getLaravelForgeConnectionListItem, + LaravelForgeConnectionMethod, + validateLaravelForgeConnectionCredentials +} from "./laravel-forge"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; @@ -187,6 +192,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), + getLaravelForgeConnectionListItem(), getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), @@ -316,6 +322,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LaravelForge]: validateLaravelForgeConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, @@ -368,6 +375,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -463,7 +471,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, - [AppConnection.Redis]: platformManagedCredentialsNotSupported + [AppConnection.Redis]: platformManagedCredentialsNotSupported, + [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index e3235d2f7..c01d9d1b4 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -28,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", + [AppConnection.LaravelForge]: "Laravel Forge", [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", @@ -70,6 +71,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { + 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/app-connection/laravel-forge/index.ts b/backend/src/services/app-connection/laravel-forge/index.ts new file mode 100644 index 000000000..9a3637d06 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-connection-enums"; +export * from "./laravel-forge-connection-fns"; +export * from "./laravel-forge-connection-schemas"; +export * from "./laravel-forge-connection-types"; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts new file mode 100644 index 000000000..548d3adfb --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts @@ -0,0 +1,3 @@ +export enum LaravelForgeConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts new file mode 100644 index 000000000..e63c659a7 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts @@ -0,0 +1,165 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; +import { + TLaravelForgeConnection, + TLaravelForgeConnectionConfig, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + TRawLaravelForgeOrganization, + TRawLaravelForgeServer, + TRawLaravelForgeSite +} from "./laravel-forge-connection-types"; + +export const getLaravelForgeConnectionListItem = () => { + return { + name: "Laravel Forge" as const, + app: AppConnection.LaravelForge as const, + methods: Object.values(LaravelForgeConnectionMethod) as [LaravelForgeConnectionMethod.ApiToken] + }; +}; + +export const validateLaravelForgeConnectionCredentials = async (config: TLaravelForgeConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + // Using the /api/me endpoint to validate the API token + await request.get(`${IntegrationUrls.LARAVELFORGE_API_URL}/api/me`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return inputCredentials; +}; + +type TLaravelForgeApiResponse = { + data: T[]; + links?: { + next?: string; + }; + meta?: { + next_cursor?: string; + prev_cursor?: string | null; + }; +}; + +const fetchAllPages = async ( + apiToken: string, + url: string, + params?: Record +): Promise => { + const allItems: T[] = []; + let nextUrl: string | null = url; + const queryParams = params || {}; + + while (nextUrl) { + try { + const response: { data: TLaravelForgeApiResponse } = await request.get>(nextUrl, { + params: queryParams, + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + + if (!response?.data?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${url}: Response was empty or malformed` + }); + } + + allItems.push(...response.data.data); + + if (response.data.links?.next) { + nextUrl = response.data.links.next; + } else { + nextUrl = null; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${url}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +}; + +export const listLaravelForgeOrganizations = async ( + appConnection: TLaravelForgeConnection +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawOrganizations = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs` + ); + + return rawOrganizations.map((org: TRawLaravelForgeOrganization) => ({ + id: org.id, + name: org.attributes.name, + slug: org.attributes.slug + })); +}; + +export const listLaravelForgeServers = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawServers = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers` + ); + + return rawServers.map((server: TRawLaravelForgeServer) => ({ + id: server.id, + name: server.attributes.name + })); +}; + +export const listLaravelForgeSites = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string, + serverId: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawSites = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers/${serverId}/sites` + ); + + return rawSites.map((site: TRawLaravelForgeSite) => ({ + id: site.id, + name: site.attributes.name + })); +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts new file mode 100644 index 000000000..1647b38a1 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts @@ -0,0 +1,58 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; + +export const LaravelForgeConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API token required").describe(AppConnections.CREDENTIALS.LARAVEL_FORGE.apiToken) +}); + +const BaseLaravelForgeConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.LaravelForge) }); + +export const LaravelForgeConnectionSchema = BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema +}); + +export const SanitizedLaravelForgeConnectionSchema = z.discriminatedUnion("method", [ + BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateLaravelForgeConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(LaravelForgeConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.LaravelForge).method), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.LaravelForge).credentials + ) + }) +]); + +export const CreateLaravelForgeConnectionSchema = ValidateLaravelForgeConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.LaravelForge) +); + +export const UpdateLaravelForgeConnectionSchema = z + .object({ + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.LaravelForge).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LaravelForge)); + +export const LaravelForgeConnectionListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + app: z.literal(AppConnection.LaravelForge), + methods: z.nativeEnum(LaravelForgeConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts new file mode 100644 index 000000000..fc3c2bf80 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts @@ -0,0 +1,74 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listLaravelForgeOrganizations, + listLaravelForgeServers, + listLaravelForgeSites +} from "./laravel-forge-connection-fns"; +import { + TLaravelForgeConnection, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite +} from "./laravel-forge-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const laravelForgeConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async ( + connectionId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const organizations = await listLaravelForgeOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to list organizations for Laravel Forge connection"); + return []; + } + }; + + const listServers = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const servers = await listLaravelForgeServers(appConnection, organizationSlug); + return servers; + } catch (error) { + logger.error(error, "Failed to list servers for Laravel Forge connection"); + return []; + } + }; + + const listSites = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string, + serverId: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const sites = await listLaravelForgeSites(appConnection, organizationSlug, serverId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites for Laravel Forge connection"); + return []; + } + }; + + return { + listOrganizations, + listServers, + listSites + }; +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts new file mode 100644 index 000000000..ad134ef3d --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts @@ -0,0 +1,63 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateLaravelForgeConnectionSchema, + LaravelForgeConnectionSchema, + ValidateLaravelForgeConnectionCredentialsSchema +} from "./laravel-forge-connection-schemas"; + +export type TLaravelForgeConnection = z.infer; + +export type TLaravelForgeConnectionInput = z.infer & { + app: AppConnection.LaravelForge; +}; + +export type TValidateLaravelForgeConnectionCredentialsSchema = typeof ValidateLaravelForgeConnectionCredentialsSchema; + +export type TLaravelForgeConnectionConfig = DiscriminativePick< + TLaravelForgeConnectionInput, + "method" | "app" | "credentials" +> & { + orgSlug: string; +}; + +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; + +export type TRawLaravelForgeOrganization = { + id: string; + attributes: { + name: string; + slug: string; + }; +}; + +export type TRawLaravelForgeServer = { + id: string; + attributes: { + name: string; + }; +}; + +export type TRawLaravelForgeSite = { + id: string; + attributes: { + name: 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-service.ts b/backend/src/services/kms/kms-service.ts index 4de44a345..4e5b48006 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -742,23 +742,27 @@ export const kmsServiceFactory = ({ if (!project.kmsSecretManagerEncryptedDataKey) { const lock = await keyStore .acquireLock([KeyStorePrefixes.KmsProjectDataKeyCreation, projectId], 3000, { retryCount: 0 }) - .catch(() => null); + .catch((err) => { + logger.error(err, "KMS. Failed to acquire lock."); + return null; + }); try { if (!lock) { await keyStore.waitTillReady({ key: `${KeyStorePrefixes.WaitUntilReadyKmsProjectDataKeyCreation}${projectId}`, keyCheckCb: (val) => val === "true", - waitingCb: () => logger.debug("KMS. Waiting for secret manager data key to be created"), + waitingCb: () => logger.info("KMS. Waiting for secret manager data key to be created"), delay: 500 }); project = await projectDAL.findById(projectId, trx); } else { + logger.info(`KMS. Generating KMS key for project ${projectId}`); const projectDataKey = await (trx || projectDAL).transaction(async (tx) => { project = await projectDAL.findById(projectId, tx); if (project.kmsSecretManagerEncryptedDataKey) { - return; + return project.kmsSecretManagerEncryptedDataKey; } const dataKey = crypto.randomBytes(32); 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 8e0892bd1..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"; @@ -12,6 +13,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { PgSqlLock } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { @@ -47,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; @@ -61,7 +67,8 @@ export const secretFolderServiceFactory = ({ folderCommitService, projectDAL, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -534,13 +541,19 @@ export const secretFolderServiceFactory = ({ projectId, env, parentId, - idOrName + idOrName, + actor }: { projectId: string; env: TProjectEnvironments; parentId: string; idOrName: string; + actor: ActorType; }) => { + if (actor === ActorType.IDENTITY) { + return; + } + let targetFolder = await folderDAL .findOne({ envId: env.id, @@ -638,7 +651,8 @@ export const secretFolderServiceFactory = ({ actorAuthMethod, environment, path: secretPath, - idOrName + idOrName, + forceDelete = false }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -664,7 +678,7 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ @@ -690,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, @@ -1315,7 +1345,7 @@ export const secretFolderServiceFactory = ({ }); } - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ 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/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index c739c5ad2..42c135c88 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -258,10 +258,6 @@ export const fnSecretsV2FromImports = async ({ })[]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; - const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL); - - stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] }; - const processedImports: TSecretImportSecretsV2[] = []; while (stack.length) { @@ -299,7 +295,9 @@ export const fnSecretsV2FromImports = async ({ ); const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); - sanitizedImports.forEach(({ importPath, importEnv }) => { + const processedBatchImports = await processReservedImports(sanitizedImports, secretImportDAL); + + processedBatchImports.forEach(({ importPath, importEnv }) => { cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); }); // now we need to check recursively deeper imports made inside other imports @@ -308,7 +306,7 @@ export const fnSecretsV2FromImports = async ({ const deeperImportsGroupByFolderId = groupBy(deeperImports, (i) => i.folderId); const isFirstIteration = !processedImports.length; - sanitizedImports.forEach(({ importPath, importEnv, id, folderId }, i) => { + processedBatchImports.forEach(({ importPath, importEnv, id, folderId }, i) => { const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) .filter((item) => diff --git a/backend/src/services/secret-sync/laravel-forge/index.ts b/backend/src/services/secret-sync/laravel-forge/index.ts new file mode 100644 index 000000000..f38e2a06b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-sync-constants"; +export * from "./laravel-forge-sync-fns"; +export * from "./laravel-forge-sync-schemas"; +export * from "./laravel-forge-sync-types"; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts new file mode 100644 index 000000000..7bde155ec --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const LARAVEL_FORGE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Laravel Forge", + destination: SecretSync.LaravelForge, + connection: AppConnection.LaravelForge, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts new file mode 100644 index 000000000..bbb0f354e --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts @@ -0,0 +1,207 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { + LaravelForgeSecret, + TGetLaravelForgeSecrets, + TLaravelForgeSecrets, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge-sync-types"; + +const getLaravelForgeSecretsRaw = async ({ apiToken, orgSlug, serverId, siteId }: TGetLaravelForgeSecrets) => { + const { data } = await request.get( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + + return data.data.attributes.content; +}; + +const parseEnv = (str: string) => { + const lines = str.split("\n"); + const parsed: { key: string; value: string }[] = []; + + let i = 0; + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Skip empty lines and comments + if (trimmed === "" || trimmed.startsWith("#")) { + i += 1; + // eslint-disable-next-line no-continue + continue; + } + + if (trimmed.includes("=")) { + const equalIndex = trimmed.indexOf("="); + const key = trimmed.substring(0, equalIndex).trim(); + const valueRaw = trimmed.substring(equalIndex + 1).trim(); + + // Check if value starts with a quote + const startsWithDoubleQuote = valueRaw.startsWith('"'); + const startsWithSingleQuote = valueRaw.startsWith("'"); + + if (startsWithDoubleQuote || startsWithSingleQuote) { + const quoteChar = startsWithDoubleQuote ? '"' : "'"; + + const closingQuoteIndex = valueRaw.indexOf(quoteChar, 1); + + if (closingQuoteIndex !== -1) { + // Single-line quoted value + const value = valueRaw.slice(1, closingQuoteIndex); + parsed.push({ key, value }); + i += 1; + } else { + // Multiline quoted value - collect lines until closing quote + let value = valueRaw.slice(1); + i += 1; + + while (i < lines.length) { + const nextLine = lines[i]; + const closingIndex = nextLine.indexOf(quoteChar); + + if (closingIndex !== -1) { + value += `\n${nextLine.substring(0, closingIndex)}`; + parsed.push({ key, value }); + i += 1; + break; + } else { + value += `\n${nextLine}`; + i += 1; + } + } + } + } else { + // Unquoted value + parsed.push({ key, value: valueRaw }); + i += 1; + } + } else { + i += 1; + } + } + + return parsed; +}; + +const getLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials): Promise => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + const secrets = await getLaravelForgeSecretsRaw({ apiToken, orgSlug, serverId, siteId }); + + const parsedSecrets = parseEnv(secrets); + + return parsedSecrets; +}; + +const buildEnvString = (secrets: LaravelForgeSecret[]) => { + if (secrets.length === 0) { + return "# .env"; + } + + return secrets + .map((secret) => { + const { value } = secret; + + if (value.includes(`"`)) { + return `${secret.key}='${value}'`; + } + + if (value.includes(" ") || value.includes("\n") || value.includes(`'`)) { + return `${secret.key}="${value}"`; + } + return `${secret.key}=${value}`; + }) + .join("\n"); +}; + +const updateLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials, envString: string) => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + await request.put( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + environment: envString + }, + + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); +}; + +export const LaravelForgeSyncFns = { + async syncSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const secrets = await getLaravelForgeSecrets(secretSync); + + // Create a map of the existing secrets + const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value])); + + for (const [key, { value }] of Object.entries(secretMap)) { + // Add the new secrets to the map + updatedSecretsMap.set(key, value); + } + + if (!disableSecretDeletion) { + secrets.forEach((secret) => { + if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return; + + if (!secretMap[secret.key]) { + updatedSecretsMap.delete(secret.key); + } + }); + } + + const updatedSecrets = Array.from(updatedSecretsMap.entries()).map(([key, value]) => ({ key, value })); + + const envString = buildEnvString(updatedSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + }, + + async getSecrets(secretSync: TLaravelForgeSyncWithCredentials): Promise { + const secrets = await getLaravelForgeSecrets(secretSync); + return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }])); + }, + + async removeSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const existingSecrets = await getLaravelForgeSecrets(secretSync); + + const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key)); + + if (newSecrets.length === existingSecrets.length) { + return; + } + + const envString = buildEnvString(newSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + } +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts new file mode 100644 index 000000000..168ebde3b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -0,0 +1,68 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const slugValidator = (val: string) => { + return new RE2("^[a-z0-9.-]+$").test(val) && !new RE2(".[-]$").test(val); +}; + +const LaravelForgeSyncDestinationConfigSchema = z.object({ + orgSlug: z + .string() + .min(1, "Org Slug is required") + .max(512, "Org Slug cannot exceed 512 characters") + .refine( + (val) => slugValidator(val), + "Org Slug can only contain lowercase letters, numbers, dots, and dashes, and cannot end with a dot or dash." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgSlug), + orgName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgName), + serverId: z + .string() + .min(1, "Server ID is required") + .refine((val) => !Number.isNaN(Number(val)), "Server ID must be a valid integer") + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverId), + serverName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverName), + siteId: z.string().min(1, "Site ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteId), + siteName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteName) +}); + +const LaravelForgeSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const LaravelForgeSyncSchema = BaseSecretSyncSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const CreateLaravelForgeSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const UpdateLaravelForgeSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema.optional() +}); + +export const LaravelForgeSyncListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + connection: z.literal(AppConnection.LaravelForge), + destination: z.literal(SecretSync.LaravelForge), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts new file mode 100644 index 000000000..faa5dffa4 --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts @@ -0,0 +1,41 @@ +import z from "zod"; + +import { TLaravelForgeConnection } from "@app/services/app-connection/laravel-forge"; + +import { + CreateLaravelForgeSyncSchema, + LaravelForgeSyncListItemSchema, + LaravelForgeSyncSchema +} from "./laravel-forge-sync-schemas"; + +export type TLaravelForgeSyncListItem = z.infer; + +export type TLaravelForgeSync = z.infer; + +export type TLaravelForgeSyncInput = z.infer; + +export type TLaravelForgeSyncWithCredentials = TLaravelForgeSync & { + connection: TLaravelForgeConnection; +}; + +export type TGetLaravelForgeSecrets = { + apiToken: string; + orgSlug: string; + serverId: string; + siteId: string; +}; + +export type TLaravelForgeSecrets = { + data: { + id: string; + type: string; + attributes: { + content: string; + }; + }; +}; + +export type LaravelForgeSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index fe0dc9f56..235b3db3a 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a6faebd1e..85fc27250 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -49,6 +49,8 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; +import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; @@ -91,7 +93,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, - [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION + [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, + [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -277,6 +280,8 @@ export const SecretSyncFns = { return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -393,6 +398,9 @@ export const SecretSyncFns = { case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; + case SecretSync.LaravelForge: + secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -486,6 +494,8 @@ export const SecretSyncFns = { return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 1fbc66cca..0ec8aede0 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,7 +32,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", - [SecretSync.Bitbucket]: "Bitbucket" + [SecretSync.Bitbucket]: "Bitbucket", + [SecretSync.LaravelForge]: "Laravel Forge" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -65,7 +66,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -98,7 +100,8 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, - [SecretSync.Bitbucket]: SecretSyncPlanType.Regular + [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, + [SecretSync.LaravelForge]: SecretSyncPlanType.Regular }; export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { @@ -140,7 +143,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], - [SecretSync.Bitbucket]: [] + [SecretSync.Bitbucket]: [], + [SecretSync.LaravelForge]: [] }; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; @@ -199,5 +203,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx new file mode 100644 index 000000000..e2d8a0f02 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..675551508 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..541a393f9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/laravel-forge/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx new file mode 100644 index 000000000..209eb6514 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx new file mode 100644 index 000000000..b00d06818 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/laravel-forge/{connectionId}" +--- + + + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx new file mode 100644 index 000000000..076571648 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx new file mode 100644 index 000000000..303c82079 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..12602b1db --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..f5082df25 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/laravel-forge/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx new file mode 100644 index 000000000..c86085e95 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx new file mode 100644 index 000000000..fa64bf933 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx new file mode 100644 index 000000000..7b4c3a752 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx new file mode 100644 index 000000000..ce638b4cb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx new file mode 100644 index 000000000..9f9f4a28f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index b62571055..2b4c9a286 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -125,6 +125,7 @@ "integrations/app-connections/hashicorp-vault", "integrations/app-connections/heroku", "integrations/app-connections/humanitec", + "integrations/app-connections/laravel-forge", "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/mysql", @@ -420,6 +421,7 @@ "documentation/guides/node", "documentation/guides/python", "documentation/guides/nextjs-vercel", + "documentation/guides/kubernetes-operator", "documentation/guides/microsoft-power-apps" ] } @@ -550,6 +552,7 @@ "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", @@ -1787,6 +1790,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/app-connections/laravel-forge/list", + "api-reference/endpoints/app-connections/laravel-forge/available", + "api-reference/endpoints/app-connections/laravel-forge/get-by-id", + "api-reference/endpoints/app-connections/laravel-forge/get-by-name", + "api-reference/endpoints/app-connections/laravel-forge/create", + "api-reference/endpoints/app-connections/laravel-forge/update", + "api-reference/endpoints/app-connections/laravel-forge/delete" + ] + }, { "group": "LDAP", "pages": [ @@ -2267,6 +2282,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/secret-syncs/laravel-forge/list", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-id", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-name", + "api-reference/endpoints/secret-syncs/laravel-forge/create", + "api-reference/endpoints/secret-syncs/laravel-forge/update", + "api-reference/endpoints/secret-syncs/laravel-forge/delete", + "api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets", + "api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets" + ] + }, { "group": "Netlify", "pages": [ diff --git a/docs/documentation/guides/kubernetes-operator.mdx b/docs/documentation/guides/kubernetes-operator.mdx new file mode 100644 index 000000000..fc42b8d2f --- /dev/null +++ b/docs/documentation/guides/kubernetes-operator.mdx @@ -0,0 +1,239 @@ +--- +title: "Managing Secrets With Kubernetes Operator" +sidebarTitle: "Kubernetes Operator" +description: "How to use the Infisical Kubernetes Operator to Push Secrets, Pull Secrets, and Generate Dynamic Secrets within your clusters." +--- + +Infisical's Kubernetes Operator provides a seamless, secure, and automated way to synchronize secrets between your Infisical instance and your Kubernetes clusters. The Operator's three Custom Resource Definitions (CRDs) make this possible. In this guide, we provide the necessary CRDs and configurations for your kubernetes cluster, but you can customize them to fit your use-case. + +In this guide, we'll walk through how to: + +1. **Install the Infisical Operator on your Kubernetes cluster**. +2. **Configure Authentication using Kubernetes Service Accounts**. +3. **Use Each of the three CRDs**. + - **InfisicalSecret** [Sync secrets from Infisical to Kubernetes] + - **InfisicalPushSecret** [Sync secrets from Kubernetes to Infisical] + - **InfisicalDynamicSecret** [Manage Dynamic Secrets and automatically create time-bound leases] + +## Prerequisites + +Before we begin, make sure your environment is ready +1. Installed tools + - [helm](https://helm.sh/docs/intro/install/), [git](https://git-scm.com/downloads), [kubectl](https://kubernetes.io/docs/tasks/tools/) +2. Kubernetes Cluster + - Ensure you have access to a running cluster and connect with kubectl +3. PostgreSQL Cluster (for InfisicalDynamicSecret) + - Ensure you have a running database that you have access to +4. Clone [infisical-guides-source-code](https://github.com/Infisical/infisical-guides-source-code) repository +5. Access to an Infisical instance (cloud or self-hosted) + +## Step-By-Step Guide + + + + +The [Infisical Operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview) runs inside your cluster and is responsible for handling secret synchronization events. + +```console +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +helm install infisical-operator infisical-helm-charts/secrets-operator +``` + +Verify the operator pod is running: + +```console +kubectl get pods -n default +``` + + + +The operator uses a [Machine Identity](https://infisical.com/docs/documentation/platform/identities/machine-identities) to authenticate with Infisical through the Kubernetes Auth Method +1. Login to [Infisical](https://app.infisical.com/) +2. Select **Organization Access** from the left navigational pane +3. Create an Identity and give it a name and a role +4. Once the Machine Identity is created, copy the **Identity ID** (will be used later) +5. Select the created Machine Identity, and add a [Kubernetes Authentication Method](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth). Use these configurations: + - **Allowed Service Account Names**: infisical-service-account, default + - **Allowed Namespaces**: default + - **Kubernetes Host URL** can be found by running ```kubectl cluster-info``` + - **Token Reviewer JWT / CA Certificate**: We will be generating these two later and adding them in later, leave it blank for now +6. Once the Machine Identity has been created, navigate back to **Overview** +7. Now select **Add New Project** + - Add a **Project Name**, and select **Secrets Management** as the product type + - Add a description (Optional). +8. Once the Project is created, navigate into the Project to **Add Secrets**. You can add any key-value pair for this example, however if you want to use the InfisicalSecret CRD example provided in this demo, use the following configurations: + - **Key**: SMTP_HOST + - **Value**: smtp@gmail.com + - **Tags**: N/A (Not needed for this demonstration) + - **Environments**: Production +9. Now lets navigate to the **Project Access** tab on the left hand navigation pane. + - Add the machine identity we created, and give it Admin permissions (just for demonstration purposes) + + + + + +Now we will be interacting with the local repository you cloned earlier. Make sure you are in the directory that contains the yaml configurations. Assuming you are in your root user directory: + +```console +cd infisical-guides-source-code/kubernetes-operator-demo +``` + +1. Create the ```infisical-token-reviewer``` service account. This Manifest creates a **service account** that the Infisical Operator uses to authenticate with Kubernetes for token reviews. It allows Infisical to validate Kubernetes tokens securely during the Machine Identity authentication process. +```console +kubectl apply -f infisical-reviewer-service-account.yaml +``` +2. Create the token for the reviewer service account. This Yaml defines a **service account token secret** linked to the reviewer account created above. It generates a JWT token that Infisical uses for the Kubernetes Auth Method in your Machine Identity configuration. +```console +kubectl apply -f service-account-reviewer-token.yaml +``` +3. This file binds the ```infisical-token-reviewer``` service account to the built-in ```system:auth-delegator``` ClusterRole. That role allows the service account to perform **token review** and **authentication delegation** requests on behalf of other service accounts - a key part of Kubernetes-based identity verification. Without this binding, the Infisical Operator wouldn't have permission to validate tokens. +```console +kubectl apply -f cluster-role-binding.yaml +``` +4. Create the service account that will be used by the InfisicalSecret. This file creates a **dedicated service account** ```infisical-service-account``` that the Infisical Operator uses to access and sync secrets within your cluster. It operates as the Operator's working identity in your cluster, separate from the token reviewer. +```console +kubectl apply -f infisical-service-account.yaml +``` +5. Create the token for the Infisical service account. This manifest defines a **token secret** for the ```infisical-service-account```. It allows the Infisical operator to authenticate against Infisical's API when syncing secrets. The token will then be manually patched and associated with the service account to make sure Kubernetes mains it persistently. +```console +kubectl apply -f infisical-service-account-token.yaml +``` +6. Apply the patch to manually associate the token secret +```console +kubectl patch serviceaccount infisical-service-account -p '{"secrets": [{"name": "infisical-service-account-token"}]}' -n default +``` +7. Create the **JWT Token** and **Certificate** and add it to the **Machine Identity** we created under **Kubernetes Auth**. For the generated CA, navigate to the **Advanced** tab to paste the certificate: +- JWT Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.token}' | base64 -d +``` + +- CA Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.ca\.crt}' | base64 -d +``` + + + +1. Check to see if the service accounts were created + +```console +kubectl get serviceaccount -n default | grep infisical +``` + +2. Verify the tokens were created and linked + +```console +kubectl get secrets -n default | grep infisical +``` + + + +The [InfisicalSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-secret-crd) CRD tells the operator to sync secrets from Infisical to Kubernetes. By referencing your ```identityID```, ```projectSlug```, and ```envSlug```, this CRD tells the Infisical Operator which Infisical secrets to fetch and how to format them into a Kubernetes Secret. Make sure to edit the provided CRD to match your specific Machine Identity ID, Project ID, and which environment your secrets are being pulled from (default is prod). + - **Project Slug**: Can be found when you select your project and navigate to settings + - **Identity ID**: Can be found when you select your machine identity from your organization's access control + +1. After editing the ```example-infisical-secret-crd.yaml``` to contain your demo-specific values, apply the yaml in your cluster +```console +kubectl apply -f example-infisical-secret-crd.yaml +``` + + + + +1. Check that the ```InfisicalSecret``` was created successfully +```console +kubectl get infisicalsecret -n default +``` +2. Check that the operator created the ```managed-secret``` +```console +kubectl get secret managed-secret -n default +``` +3. View the secret contents (base64 encoded) +```console +kubectl get secret managed-secret -n default -o jsonpath='{.data}' | jq +``` + + + +1. Deploy the nginx demo deployment that will use the managed secret. +```console +kubectl apply -f demo-deployment.yaml +``` +2. Wait 15-20 seconds and then verify the deployments +```console +kubectl get deployments +kubectl get pods -l app=nginx +``` + + + +1. Check that the environment variable is in the running pod. If everything was successful, at this point you should be able to see the secret populate in the kubernetes pod and have a successful **sync** from Infisical to Kubernetes. +```console +kubectl exec -it $(kubectl get pod -l app=nginx -o jsonpath='{.items[0].metadata.name}') -- env | grep SMTP +``` + + + +Now that we have successfully synced secrets from Infisical to Kubernetes, lets explore how we can push **Kubernetes Secrets** to Infisical. + + 1. Either create a **Kubernetes Secret** via yaml, or use the one in the repository. +```console +kubectl apply -f source-secret.yaml +``` + 2. Verify creation of the secret +```console +kubectl get secret push-secret-demo -n default -o yaml +``` + + + +The [InfisicalPushSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-push-secret-crd) CRD tells the operator to sync secrets from Kubernetes to Infisical. Make sure you edit the CRD to include the specific **Project Slug**, and **Identity ID**. The other values present in ```example-push-secret.yaml``` should be configured based on the previously committed yaml configurations. + + 1. Apply the InfisicalPushSecret CRD provided after making the necessary changes +```console +kubectl apply -f example-push-secret-crd.yaml +``` + + 2. Once your CRD has been configured, go back to your project within Infisical and check to see if your secrets have populated there. + +![secrets dashboard](../../images/push-secret.png) + + + + +The [InfisicalDynamicSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd) CRD allows you to sync dynamic secrets and create leases automatically in Kubernetes as native **Kubernetes Secret** resources Any Pod, Deployment, or other Kubernetes resource can make use of dynamic secrets from Infisical just like any other Kubernetes secret. + + 1. Navigate to your Infisical **Project** and click on the dropdown next to **Add Secret**. From here you will select **Add Dynamic Secret** + - Select **SQL Database** as the service you would like to connect to. + - Select **PostegreSQL** as the database service. Enter in the connection details for your database, specifically the **Host**, **Port**, **User**, **Password**, and **Database Name**. + - For the **Secret Name**, if you want to use the same name as the one in the cloned **InfisicalDynamicSecret** CRD, use the name **dynamic-secret-lease**. Otherwise you will need to change the **dynamicSecret.secretName** config in the InfisicalDynamicSecret CRD to whatever you name the secret here. + - In the CA (SSL) section, make sure to upload the **CA Certificate** for your database. + - Finally, select **Prod** as the environment (we are keeping this configuration as part of the demonstration). + +![secrets dashboard dynamic](../../images/dynamic-secret.png) + + 2. Edit the ```dynamic-secret-crd``` with the proper machine **Identity ID**, **Project Slug**, **dynamicSecret.secretName** (same as the **Secret Name** you gave to the dynamic secret in Infisical), and managedSecretReference.secretName (name of the kubernetes secret that Infisical Operator will create/populate in the cluster). + - If you want to keep the **managedSecretReference.secretName** then you can leave it as **dynamic-secret-test** + + 3. Once the changes have been saved, apply the yaml: + + ```console + kubectl apply -f dynamic-secret-crd.yaml + ``` + + 4. After applying the CRD, you should notice that the dynamic secret lease has been created and synced with your cluster. Verify by running: + +```console +kubectl get secret dynamic-secret-test -n default -o yaml +``` + + 5. Once the dynamic secret lease has been created, you should see that the secret has data that contains the lease credentials. + +![dynamic secrets output](../../images/dynamic-secret-crd.png) + + + +**Congratulations! You successfully managed secrets with Kubernetes.** \ No newline at end of file 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/app-connections/laravel-forge/api-token-create-form.png b/docs/images/app-connections/laravel-forge/api-token-create-form.png new file mode 100644 index 000000000..895da4d77 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-create-form.png differ diff --git a/docs/images/app-connections/laravel-forge/api-token-generated.png b/docs/images/app-connections/laravel-forge/api-token-generated.png new file mode 100644 index 000000000..8edc62261 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png new file mode 100644 index 000000000..69fe3b2b3 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-form.png b/docs/images/app-connections/laravel-forge/app-connection-form.png new file mode 100644 index 000000000..e03289a65 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-form.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-generated.png b/docs/images/app-connections/laravel-forge/app-connection-generated.png new file mode 100644 index 000000000..badc5d8c5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-option.png b/docs/images/app-connections/laravel-forge/app-connection-option.png new file mode 100644 index 000000000..50ac89c27 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-option.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-profile.png b/docs/images/app-connections/laravel-forge/app-connection-profile.png new file mode 100644 index 000000000..18e66a5a5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-profile.png differ diff --git a/docs/images/dynamic-secret-crd.png b/docs/images/dynamic-secret-crd.png new file mode 100644 index 000000000..8b20dbd2e Binary files /dev/null and b/docs/images/dynamic-secret-crd.png differ diff --git a/docs/images/dynamic-secret.png b/docs/images/dynamic-secret.png new file mode 100644 index 000000000..812b2dfc3 Binary files /dev/null and b/docs/images/dynamic-secret.png differ 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/docs/images/push-secret.png b/docs/images/push-secret.png new file mode 100644 index 000000000..a70228434 Binary files /dev/null and b/docs/images/push-secret.png differ diff --git a/docs/images/secret-syncs/laravel-forge/select-option.png b/docs/images/secret-syncs/laravel-forge/select-option.png new file mode 100644 index 000000000..a10f3a437 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/select-option.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-created.png b/docs/images/secret-syncs/laravel-forge/sync-created.png new file mode 100644 index 000000000..3e85f0879 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-created.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-destination.png b/docs/images/secret-syncs/laravel-forge/sync-destination.png new file mode 100644 index 000000000..735471a7a Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-destination.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-details.png b/docs/images/secret-syncs/laravel-forge/sync-details.png new file mode 100644 index 000000000..3588f88db Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-details.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-options.png b/docs/images/secret-syncs/laravel-forge/sync-options.png new file mode 100644 index 000000000..0c83e0a19 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-options.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-review.png b/docs/images/secret-syncs/laravel-forge/sync-review.png new file mode 100644 index 000000000..2d617a8b0 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-review.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-source.png b/docs/images/secret-syncs/laravel-forge/sync-source.png new file mode 100644 index 000000000..5bffb0ba9 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-source.png differ diff --git a/docs/integrations/app-connections/laravel-forge.mdx b/docs/integrations/app-connections/laravel-forge.mdx new file mode 100644 index 000000000..67ce55270 --- /dev/null +++ b/docs/integrations/app-connections/laravel-forge.mdx @@ -0,0 +1,107 @@ +--- +title: "Laravel Forge Connection" +description: "Learn how to configure a Laravel Forge Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://forge.laravel.com/docs/api#create-a-new-api-token) to connect with Laravel Forge. + +## Create Laravel Forge API Token + + + + ![Laravel Forge User Settings](/images/app-connections/laravel-forge/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/laravel-forge/app-connection-create-api-token.png) + + + Provide a name for your token and select the following permissions: + - `user:view` + - `organization:view` + - `server:view` + - `site:manage-environment` + + Then click 'Add token'. + + ![Token Form](/images/app-connections/laravel-forge/api-token-create-form.png) + + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/laravel-forge/api-token-generated.png) + + + + +## Create a Laravel Forge Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Laravel Forge** Connection from the list of integrations. + ![Select Laravel Forge Connection](/images/app-connections/laravel-forge/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + ![Laravel Forge Connection Modal](/images/app-connections/laravel-forge/app-connection-form.png) + + + After submitting the form, your **Laravel Forge Connection** will be successfully created and ready to use with your Infisical project. + ![Laravel Forge Connection Created](/images/app-connections/laravel-forge/app-connection-generated.png) + + + + + + + To create a Laravel Forge Connection via API, send a request to the [Create Laravel Forge Connection](/api-reference/endpoints/app-connections/laravel-forge/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-connection", + "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-laravel-forge-connection", + "description": null, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-10-13T10:15:00.000Z", + "updatedAt": "2025-10-13T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "laravel-forge", + "method": "api-token", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/laravel-forge.mdx b/docs/integrations/secret-syncs/laravel-forge.mdx new file mode 100644 index 000000000..c93abb94a --- /dev/null +++ b/docs/integrations/secret-syncs/laravel-forge.mdx @@ -0,0 +1,157 @@ +--- +title: "Laravel Forge Sync" +description: "Learn how to configure a Laravel Forge Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Laravel Forge Connection](/integrations/app-connections/laravel-forge) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Laravel Forge](/images/secret-syncs/laravel-forge/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/laravel-forge/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/laravel-forge/sync-destination.png) + + - **Laravel Forge Connection**: The Laravel Forge Connection to authenticate with. + - **Organization**: The Organization in which the server and site reside. + - **Server**: The Server on which the site resides. + - **Site**: The Site for which secrets should be synced. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/laravel-forge/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Laravel Forge when keys conflict. + - **Import Secrets (Prioritize Laravel Forge)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Laravel Forge over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Laravel Forge Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/laravel-forge/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Laravel Forge Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/laravel-forge/sync-review.png) + + + If enabled, your Laravel Forge Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/laravel-forge/sync-created.png) + + + + + + + To create a **Laravel Forge Sync**, make an API request to the [Create Laravel Forge Sync](/api-reference/endpoints/secret-syncs/laravel-forge/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to laravel forge site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-laravel-forge-sync", + "description": "sync to laravel forge site", + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "laravel-forge", + "name": "my-laravel-forge-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "laravel-forge", + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx index fd98fac41..c1c51d452 100644 --- a/docs/integrations/secret-syncs/netlify.mdx +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -78,6 +78,7 @@ description: "Learn how to configure a Netlify Sync for Infisical." ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + @@ -157,5 +158,6 @@ description: "Learn how to configure a Netlify Sync for Infisical." } } ``` + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 951a643dd..cfc65d1fd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -45,7 +45,8 @@ export const AppConnectionsBrowser = () => { {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index d32f5ac04..3598bf68e 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -36,7 +36,8 @@ export const SecretSyncsBrowser = () => { {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, - {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx index 7257b3d04..ebbe34d75 100644 --- a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx @@ -17,7 +17,7 @@ export const SecretSyncModalHeader = ({ destination, isConfigured }: Props) => { {`${destinationDetails.name}
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx new file mode 100644 index 000000000..90409da4f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx @@ -0,0 +1,138 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + useLaravelForgeConnectionListOrganizations, + useLaravelForgeConnectionListServers, + useLaravelForgeConnectionListSites +} from "@app/hooks/api/appConnections/laravel-forge"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const LaravelForgeSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.LaravelForge } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const orgSlug = useWatch({ name: "destinationConfig.orgSlug", control }); + const serverId = useWatch({ name: "destinationConfig.serverId", control }); + + const { data: organizations, isLoading: isOrganizationsLoading } = + useLaravelForgeConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const { data: servers, isLoading: isServersLoading } = useLaravelForgeConnectionListServers( + connectionId, + orgSlug, + { + enabled: Boolean(connectionId && orgSlug) + } + ); + + const { data: sites, isLoading: isSitesLoading } = useLaravelForgeConnectionListSites( + connectionId, + orgSlug, + serverId, + { + enabled: Boolean(connectionId && orgSlug && serverId) + } + ); + + const handleChangeConnection = () => { + setValue("destinationConfig.orgSlug", ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + setValue("destinationConfig.orgName", ""); + setValue("destinationConfig.serverName", ""); + setValue("destinationConfig.siteName", ""); + }; + + return ( + <> + + + ( + + org.slug === value) ?? null} + onChange={(option) => { + const selectedOrg = option as SingleValue; + onChange(selectedOrg?.slug ?? ""); + setValue("destinationConfig.orgName", selectedOrg?.name ?? ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + server.id === value) ?? null} + onChange={(option) => { + const selectedServer = option as SingleValue; + onChange(selectedServer?.id ?? ""); + setValue("destinationConfig.serverName", selectedServer?.name ?? ""); + setValue("destinationConfig.siteId", ""); + }} + options={servers} + placeholder="Select a server..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + site.id === value) ?? null} + onChange={(option) => { + const selectedSite = option as SingleValue; + onChange(selectedSite?.id ?? ""); + setValue("destinationConfig.siteName", selectedSite?.name ?? ""); + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b2186fed1..61ba54369 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; @@ -100,6 +101,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Bitbucket: return ; + case SecretSync.LaravelForge: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 4fe457179..f66cadaea 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -69,6 +69,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: case SecretSync.Bitbucket: + case SecretSync.LaravelForge: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx new file mode 100644 index 000000000..a359359f5 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const LaravelForgeSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgName = watch("destinationConfig.orgName"); + const orgSlug = watch("destinationConfig.orgSlug"); + const serverName = watch("destinationConfig.serverName"); + const serverId = watch("destinationConfig.serverId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + + return ( + <> + {orgName || orgSlug} + {serverName || serverId || "None"} + {siteName || siteId || "None"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index d6c34fb87..44d35cd2c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -35,6 +35,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; @@ -168,6 +169,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; + case SecretSync.LaravelForge: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts new file mode 100644 index 000000000..011f835b7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const LaravelForgeSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: z.object({ + orgSlug: z.string().trim().min(1, "Org Slug required"), + orgName: z.string().trim().min(1, "Org Name required"), + serverId: z.string().trim().min(1, "Server ID required"), + serverName: z.string().trim().min(1, "Server Name required"), + siteId: z.string().trim().min(1, "Site ID required"), + siteName: z.string().trim().min(1, "Site Name required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index f4616c711..5ebc38184 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { LaravelForgeSyncDestinationSchema } from "./laravel-forge-sync-destination-schema"; import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; @@ -61,7 +62,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, - BitbucketSyncDestinationSchema + BitbucketSyncDestinationSchema, + LaravelForgeSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d0e3dcba1..bdb26c796 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -48,6 +48,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection"; import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; +import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection"; import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; @@ -56,7 +57,13 @@ import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/su export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number; icon?: IconDefinition; enterprise?: boolean } + { + name: string; + image: string; + size?: number; + icon?: IconDefinition; + enterprise?: boolean; + } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -115,7 +122,12 @@ export const APP_CONNECTION_MAP: Record< image: "Netlify.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, - [AppConnection.Redis]: { name: "Redis", image: "Redis.png" } + [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, + [AppConnection.LaravelForge]: { + name: "Laravel Forge", + image: "Laravel Forge.png", + size: 65 + } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -151,6 +163,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 6ce82cfb2..12cae6ea6 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -113,6 +113,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index e897cf0f0..66fed8a5d 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -37,5 +37,6 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/index.ts b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx new file mode 100644 index 000000000..6986a94da --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx @@ -0,0 +1,104 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TLaravelForgeOrganization, TLaravelForgeServer, TLaravelForgeSite } from "./types"; + +const laravelForgeConnectionKeys = { + all: [...appConnectionKeys.all, "laravel-forge"] as const, + listOrganizations: (connectionId: string) => + [...laravelForgeConnectionKeys.all, "organizations", connectionId] as const, + listServers: (connectionId: string, organizationSlug: string) => + [...laravelForgeConnectionKeys.all, "servers", connectionId, organizationSlug] as const, + listSites: (connectionId: string, organizationSlug: string, serverId: string) => + [...laravelForgeConnectionKeys.all, "sites", connectionId, organizationSlug, serverId] as const +}; + +export const useLaravelForgeConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeOrganization[], + unknown, + TLaravelForgeOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; + +export const useLaravelForgeConnectionListServers = ( + connectionId: string, + organizationSlug: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeServer[], + unknown, + TLaravelForgeServer[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listServers(connectionId, organizationSlug), + queryFn: async () => { + const params = { organizationSlug }; + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/servers`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug), + ...options + }); +}; + +export const useLaravelForgeConnectionListSites = ( + connectionId: string, + organizationSlug: string, + serverId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeSite[], + unknown, + TLaravelForgeSite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listSites(connectionId, organizationSlug, serverId), + queryFn: async () => { + const params = { + organizationSlug, + serverId + }; + + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/sites`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug && serverId), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/types.ts b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts new file mode 100644 index 000000000..5a646df3f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts @@ -0,0 +1,15 @@ +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index fdaae2c74..797e7a7c6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -164,6 +164,10 @@ export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.LaravelForge; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -210,7 +214,8 @@ export type TAppConnectionOption = | TDigitalOceanConnectionOption | TNetlifyConnectionOption | TOktaConnectionOption - | TAzureAdCsConnectionOption; + | TAzureAdCsConnectionOption + | TLaravelForgeConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -252,4 +257,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; + [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 9f8df7cfa..fd840d5de 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -22,6 +22,7 @@ import { TGitLabConnection } from "./gitlab-connection"; import { THCVaultConnection } from "./hc-vault-connection"; import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; +import { TLaravelForgeConnection } from "./laravel-forge-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; @@ -61,6 +62,7 @@ export * from "./gitlab-connection"; export * from "./hc-vault-connection"; export * from "./heroku-connection"; export * from "./humanitec-connection"; +export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; @@ -105,6 +107,7 @@ export type TAppConnection = | TOCIConnection | TOnePassConnection | THerokuConnection + | TLaravelForgeConnection | TRenderConnection | TFlyioConnection | TGitLabConnection diff --git a/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts b/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts new file mode 100644 index 000000000..ac48d929d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum LaravelForgeConnectionMethod { + ApiToken = "api-token" +} + +export type TLaravelForgeConnection = TRootAppConnection & { app: AppConnection.LaravelForge } & { + method: LaravelForgeConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; 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/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index c230cd9c6..efcc04b6d 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f7acda1f4..3cab195bd 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; @@ -69,7 +70,8 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync - | TBitbucketSync; + | TBitbucketSync + | TLaravelForgeSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts new file mode 100644 index 000000000..cce693a48 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts @@ -0,0 +1,20 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TLaravelForgeSync = TRootSecretSync & { + destination: SecretSync.LaravelForge; + destinationConfig: { + orgSlug: string; + orgName: string; + serverId: string; + serverName: string; + siteId: string; + siteName: string; + }; + connection: { + app: AppConnection.LaravelForge; + name: string; + id: string; + }; +}; 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/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 42b00f44a..a44c4746b 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -31,6 +31,7 @@ import { GitLabConnectionForm } from "./GitLabConnectionForm"; import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; import { HerokuConnectionForm } from "./HerokuAppConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; +import { LaravelForgeConnectionForm } from "./LaravelForgeConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; @@ -146,6 +147,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Render: return ; + case AppConnection.LaravelForge: + return ; case AppConnection.Flyio: return ; case AppConnection.GitLab: @@ -297,6 +300,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { ); case AppConnection.Render: return ; + case AppConnection.LaravelForge: + return ; case AppConnection.Flyio: return ; case AppConnection.GitLab: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.tsx new file mode 100644 index 000000000..f115942eb --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.tsx @@ -0,0 +1,136 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { + LaravelForgeConnectionMethod, + TLaravelForgeConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TLaravelForgeConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.LaravelForge) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const LaravelForgeConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.LaravelForge, + method: LaravelForgeConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx index 5774e7707..f77157945 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx @@ -19,7 +19,7 @@ export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => { {`${appDetails.name} {appDetails.icon && ( { } className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600" > -
+ {image && ( { className="mt-auto" alt={`${name} logo`} /> - {icon && ( + )} + {icon && ( +
- )} -
+
+ )}
{name}
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 +

+ +
+ + + <> +