diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 9dc767e310..5d9f384f7f 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -135,7 +135,9 @@ jobs: TAG_NAME="${{ github.ref_name }}" echo "Checking for tag: $TAG_NAME" - if gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME --silent 2>/dev/null; then + EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME") + + if [ "$EXACT_MATCH" == "refs/tags/$TAG_NAME" ]; then echo "Tag $TAG_NAME already exists, skipping..." else echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 1c03f752ab..4dbf7872b2 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -6,7 +6,7 @@ ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:20.19.5-trixie-slim AS base # Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230 -RUN npm install -g npm@11 +RUN npm install -g npm@10.9.0 FROM base AS frontend-dependencies WORKDIR /app diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index bea94d6b6b..bc80130be5 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -6,7 +6,7 @@ ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:20.19.5-trixie-slim AS base # Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230 -RUN npm install -g npm@11 +RUN npm install -g npm@10.9.0 FROM base AS frontend-dependencies diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index 112def0b3d..5d9007a001 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -2,7 +2,9 @@ import knex, { Knex } from "knex"; const parseSslConfig = (dbConnectionUri: string, dbRootCert?: string) => { let modifiedDbConnectionUri = dbConnectionUri; - let sslConfig: { rejectUnauthorized: boolean; ca: string } | boolean = false; + let sslConfig: { rejectUnauthorized: boolean; ca: string } | boolean = dbRootCert + ? { rejectUnauthorized: true, ca: Buffer.from(dbRootCert, "base64").toString("ascii") } + : false; if (dbRootCert) { const url = new URL(dbConnectionUri); diff --git a/backend/src/db/migrations/20251011141618_membership-identity-index.ts b/backend/src/db/migrations/20251011141618_membership-identity-index.ts new file mode 100644 index 0000000000..8d33b61a0f --- /dev/null +++ b/backend/src/db/migrations/20251011141618_membership-identity-index.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasScopeColumn = await knex.schema.hasColumn(TableName.Membership, "scope"); + const hasActorIdentityColumn = await knex.schema.hasColumn(TableName.Membership, "actorIdentityId"); + if (hasScopeColumn && hasActorIdentityColumn) { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.index(["scope", "actorIdentityId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasScopeColumn = await knex.schema.hasColumn(TableName.Membership, "scope"); + const hasActorIdentityColumn = await knex.schema.hasColumn(TableName.Membership, "actorIdentityId"); + if (hasScopeColumn && hasActorIdentityColumn) { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.dropIndex(["scope", "actorIdentityId"]); + }); + } +} diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index dce5bc2990..a2d3237900 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -2,7 +2,6 @@ import net from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { CronJob } from "cron"; import { OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; @@ -891,7 +890,7 @@ export const gatewayV2ServiceFactory = ({ }); }; - const $healthcheckNotify = async () => { + const healthcheckNotify = async () => { const unhealthyGateways = await gatewayV2DAL.find({ isHeartbeatStale: true }); @@ -945,18 +944,6 @@ export const gatewayV2ServiceFactory = ({ } }; - const initializeHealthcheckNotify = async () => { - logger.info("Setting up background notification process for gateway v2 health-checks"); - - await $healthcheckNotify(); - - // run every 5 minutes - const job = new CronJob("*/5 * * * *", $healthcheckNotify); - job.start(); - - return job; - }; - return { listGateways, registerGateway, @@ -965,6 +952,6 @@ export const gatewayV2ServiceFactory = ({ deleteGatewayById, heartbeat, getPamSessionKey, - initializeHealthcheckNotify + healthcheckNotify }; }; diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index 8eec7ceb77..1afccdafe5 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -25,7 +25,9 @@ export const initializeHsmModule = (envConfig: Pick { // count org identities const identityDoc = await (tx || db.replicaNode())(TableName.Membership) - .where({ status: OrgMembershipStatus.Accepted, scope: AccessScope.Organization }) + .where({ scope: AccessScope.Organization }) .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where((bd) => { if (orgId) { diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 41fe91bfc1..d791e99195 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -2,7 +2,6 @@ import { isIP } from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { CronJob } from "cron"; import { OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; @@ -1209,7 +1208,7 @@ export const relayServiceFactory = ({ return deletedRelay; }; - const $healthcheckNotify = async () => { + const healthcheckNotify = async () => { const unhealthyRelays = await relayDAL.find({ isHeartbeatStale: true }); @@ -1283,18 +1282,6 @@ export const relayServiceFactory = ({ } }; - const initializeHealthcheckNotify = async () => { - logger.info("Setting up background notification process for relay health-checks"); - - await $healthcheckNotify(); - - // run every 5 minutes - const job = new CronJob("*/5 * * * *", $healthcheckNotify); - job.start(); - - return job; - }; - return { registerRelay, getCredentialsForGateway, @@ -1302,6 +1289,6 @@ export const relayServiceFactory = ({ getRelays, deleteRelay, heartbeat, - initializeHealthcheckNotify + healthcheckNotify }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 30466fc6e6..8b9e6d2752 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2356,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." } } }; @@ -2508,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/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 335f25d5f8..7f45e3821c 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -76,7 +76,8 @@ export enum QueueName { TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", - UserNotification = "user-notification" + UserNotification = "user-notification", + HealthAlert = "health-alert" } export enum QueueJobs { @@ -124,7 +125,8 @@ export enum QueueJobs { TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", - UserNotification = "user-notification-job" + UserNotification = "user-notification-job", + HealthAlert = "health-alert" } export type TQueueJobTypes = { @@ -351,6 +353,10 @@ export type TQueueJobTypes = { name: QueueJobs.UserNotification; payload: { notifications: TCreateUserNotificationDTO[] }; }; + [QueueName.HealthAlert]: { + name: QueueJobs.HealthAlert; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 047e884d13..d7b66eb0c7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -194,6 +194,7 @@ import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkp import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; +import { healthAlertServiceFactory } from "@app/services/health-alert/health-alert-queue"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; @@ -1619,9 +1620,9 @@ export const registerRoutes = async ( const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, - identityOrgMembershipDAL, accessTokenQueue, - identityDAL + identityDAL, + membershipIdentityDAL }); const identityTokenAuthService = identityTokenAuthServiceFactory({ @@ -1807,6 +1808,7 @@ export const registerRoutes = async ( identityDAL }); + // DAILY const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, queueService, @@ -1823,6 +1825,12 @@ export const registerRoutes = async ( keyValueStoreDAL }); + const healthAlert = healthAlertServiceFactory({ + gatewayV2Service, + queueService, + relayService + }); + const dailyReminderQueueService = dailyReminderQueueServiceFactory({ reminderService, queueService, @@ -2256,6 +2264,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); + await healthAlert.init(); await pkiSyncCleanup.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); @@ -2420,16 +2429,6 @@ export const registerRoutes = async ( cronJobs.push(configSyncJob); } - const gatewayHealthcheckNotifyJob = await gatewayV2Service.initializeHealthcheckNotify(); - if (gatewayHealthcheckNotifyJob) { - cronJobs.push(gatewayHealthcheckNotifyJob); - } - - const relayHealthcheckNotifyJob = await relayService.initializeHealthcheckNotify(); - if (relayHealthcheckNotifyJob) { - cronJobs.push(relayHealthcheckNotifyJob); - } - const oauthConfigSyncJob = await initializeOauthConfigSync(); if (oauthConfigSyncJob) { cronJobs.push(oauthConfigSyncJob); 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 e5549f9fe0..c799ef0f04 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 11d9ce5e63..2e3da44206 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/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index fed56277ef..e778dbd7c4 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 71e8b2cca0..1bfa32eeb4 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/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 996cd872a1..54b70c7d3a 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 73abef78dd..464f718cea 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 e3235d2f71..c01d9d1b4a 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 { + 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 0000000000..1647b38a12 --- /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 0000000000..fc3c2bf803 --- /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 0000000000..ad134ef3d6 --- /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/health-alert/health-alert-queue.ts b/backend/src/services/health-alert/health-alert-queue.ts new file mode 100644 index 0000000000..1d65d0f4d9 --- /dev/null +++ b/backend/src/services/health-alert/health-alert-queue.ts @@ -0,0 +1,65 @@ +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type THealthAlertServiceFactoryDep = { + queueService: TQueueServiceFactory; + gatewayV2Service: Pick; + relayService: Pick; +}; + +export type THealthAlertServiceFactory = ReturnType; + +export const healthAlertServiceFactory = ({ + queueService, + gatewayV2Service, + relayService +}: THealthAlertServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.HealthAlert, + QueueJobs.HealthAlert, + { pattern: "*/5 * * * *", utc: true }, + QueueName.HealthAlert // job id + ); + + await queueService.startPg( + QueueJobs.HealthAlert, + async () => { + try { + logger.info(`${QueueName.HealthAlert}: health check alert task started`); + await gatewayV2Service.healthcheckNotify(); + await relayService.healthcheckNotify(); + logger.info(`${QueueName.HealthAlert}: health check alert task completed`); + } catch (error) { + logger.error(error, `${QueueName.HealthAlert}: health check alert failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg( + QueueJobs.HealthAlert, + "*/5 * * * *", // Schedule to run every 5 minutes + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 1b230f7a57..1f6e4616b7 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -1,4 +1,4 @@ -import { IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -7,27 +7,27 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; type TIdentityAccessTokenServiceFactoryDep = { identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityDAL: Pick; - identityOrgMembershipDAL: TIdentityOrgDALFactory; accessTokenQueue: Pick< TAccessTokenQueueServiceFactory, "updateIdentityAccessTokenStatus" | "getIdentityTokenDetailsInCache" >; + membershipIdentityDAL: Pick; }; export type TIdentityAccessTokenServiceFactory = ReturnType; export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, - identityOrgMembershipDAL, accessTokenQueue, - identityDAL + identityDAL, + membershipIdentityDAL }: TIdentityAccessTokenServiceFactoryDep) => { const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { @@ -202,8 +202,8 @@ export const identityAccessTokenServiceFactory = ({ trustedIps: trustedIps as TIp[] }); } - - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, actorIdentityId: identityAccessToken.identityId }); diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 4de44a3458..4e5b480064 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/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 288926f90c..d987c890cd 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -5,6 +5,7 @@ import { AccessScope, OrganizationsSchema, OrgMembershipRole, + OrgMembershipStatus, TableName, TMemberships, TMembershipsInsert, @@ -346,6 +347,7 @@ export const orgDALFactory = (db: TDbClient) => { .replicaNode()(TableName.Membership) .where(`${TableName.Membership}.scopeOrgId`, orgId) .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.status`, OrgMembershipStatus.Accepted) .whereNotNull(`${TableName.Membership}.actorUserId`) .count("*") .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index c739c5ad23..42c135c885 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 0000000000..f38e2a06b8 --- /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 0000000000..7bde155ec3 --- /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 0000000000..bbb0f354ef --- /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 0000000000..168ebde3b7 --- /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 0000000000..faa5dffa4c --- /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 fe0dc9f56c..235b3db3a3 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 a6faebd1ec..85fc272505 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 1fbc66ccab..0ec8aede07 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 0000000000..e2d8a0f02e --- /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 0000000000..6755515085 --- /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 0000000000..541a393f9e --- /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 0000000000..209eb6514d --- /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 0000000000..b00d068186 --- /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 0000000000..0765716488 --- /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 0000000000..303c820798 --- /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 0000000000..12602b1db2 --- /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 0000000000..f5082df25c --- /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 0000000000..c86085e95e --- /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 0000000000..fa64bf9338 --- /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 0000000000..7b4c3a7527 --- /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 0000000000..ce638b4cba --- /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 0000000000..9f9f4a28fb --- /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/contributing/platform/developing.mdx b/docs/contributing/platform/developing.mdx index 68add46031..69710baf31 100644 --- a/docs/contributing/platform/developing.mdx +++ b/docs/contributing/platform/developing.mdx @@ -43,23 +43,23 @@ docker compose -f docker-compose.dev.yml down We use [Mintlify](https://mintlify.com/) for our docs. -#### Install Mintlify CLI. +#### Install Mint CLI. ```bash -npm i -g mintlify +npm i -g mint ``` or ```bash -yarn global add mintlify +yarn global add mint ``` #### Running the docs -Go to `docs` directory and run `mintlify dev`. This will start up the docs on `localhost:3000` +Go to `docs` directory and run `mint dev`. This will start up the docs on `localhost:3000` ```bash # From the root directory -cd docs; mintlify dev; +cd docs; mint dev; ``` diff --git a/docs/docs.json b/docs/docs.json index 784b015e6c..9d2516e709 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", @@ -316,6 +317,7 @@ "self-hosting/deployment-options/linux-upgrade" ] }, + "self-hosting/guides/replication", "self-hosting/guides/upgrading-infisical", "self-hosting/configuration/envars", "self-hosting/guides/releases", @@ -549,6 +551,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", @@ -1777,6 +1780,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": [ @@ -2257,6 +2272,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/platform/folder.mdx b/docs/documentation/platform/folder.mdx index a3636a2eaa..eab62549e7 100644 --- a/docs/documentation/platform/folder.mdx +++ b/docs/documentation/platform/folder.mdx @@ -3,10 +3,10 @@ title: "Folders" description: "Learn how to organize secrets with folders." --- -Infisical Folders enable users to organize secrets using custom structures dependent on the intended use case (also known as **path-based secret storage**). +Infisical Folders enable users to organize secrets using custom structures dependent on the intended use case (also known as **path-based secret storage**). -It is great for organizing secrets around hierarchies with multiple services or types of secrets involved at large quantities. -Infisical Folders can be infinitely nested to mirror your application architecture – whether it's microservices, monorepos, +It is great for organizing secrets around hierarchies with multiple services or types of secrets involved at large quantities. +Infisical Folders can be infinitely nested to mirror your application architecture – whether it's microservices, monorepos, or any logical grouping that best suits your needs. Consider the following structure for a microservice architecture: @@ -22,7 +22,7 @@ Consider the following structure for a microservice architecture: ... ``` -In this example, we store environment variables for each microservice under each respective `/envars` folder. +In this example, we store environment variables for each microservice under each respective `/envars` folder. We also store user-specific secrets for micro-service 1 under `/service1/users`. With this folder structure in place, your applications only need to specify a path like `/microservice1/envars` to fetch secrets from there. By extending this example, you can see how path-based secret storage provides a versatile approach to manage secrets for any architecture. @@ -45,7 +45,27 @@ To delete a folder, hover over it and press the **X** button that appears on the It's possible to compare the contents of folders across environments in the **Secrets Overview** page. When you click on a folder, the table will display the items within it across environments. -In the image below, you can see that the **Development** environment is the only one that contains items +In the image below, you can see that the **Development** environment is the only one that contains items in the `/users` folder, being other folders `/user-a`, `/user-b`, ... `/user-f`. -![comparing folders](../../images/platform/folder/folders-secrets-overview.png) \ No newline at end of file +![comparing folders](../../images/platform/folder/folders-secrets-overview.png) + +### Replicating Folder Contents + +If you want to copy secrets or folders from one path to another, you can utilize the **Replicate Secrets** functionality located in the **Add Secret** dropdown. + +![replicate secrets](../../images/platform/folder/replicate-secrets.png) + +![replicate secrets modal](../../images/platform/folder/replicate-secrets-modal.png) + +First, select the **Source Environment** and the **Source Root Path** you want to copy secrets *from*. In the example provided, we select `/dev-folder` as the source root path from the Development environment. This means any secrets within `/dev-folder` from Development will be replicated. By default, these secrets are copied into the *currently active* folder/path in your target environment (e.g., the root folder of your Staging environment in this scenario). + +As a final step, you can select the specific secrets you wish to copy and then click **Replicate Secrets**. + +![replicate secrets modal](../../images/platform/folder/replicate-secrets-result.png) + +The result shows two secrets successfully copied from the `/dev-folder` in the Development environment into the root folder of the Staging environment. + + + If you do not select a **Source Root Path**, the replication will consider the contents of the *entire root* of the **Source Environment** (e.g., the Development environment). In this example that would mean copying the `/dev-folder` itself rather than just its contents. + diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index c7d4d32fa1..45d8839771 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -36,7 +36,6 @@ Enabling HSM encryption has a set of key benefits: ### Requirements - An Infisical instance with a version number that is equal to or greater than `v0.91.0`. -- If you are using Docker, your instance must be using the `infisical/infisical-fips` image. - An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others. @@ -238,7 +237,7 @@ Enabling HSM encryption has a set of key benefits: -e DB_CONNECTION_URI="<>" \ -e REDIS_URL="<>" \ -e SITE_URL="<>" \ - infisical/infisical-fips: # Replace with the version you want to use + infisical/infisical: # Replace with the version you want to use ``` We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). @@ -309,7 +308,7 @@ Enabling HSM encryption has a set of key benefits: -e DB_CONNECTION_URI="<>" \ -e REDIS_URL="<>" \ -e SITE_URL="<>" \ - infisical/infisical-fips: # Replace with the version you want to use + infisical/infisical: # Replace with the version you want to use ``` @@ -319,6 +318,192 @@ Enabling HSM encryption has a set of key benefits: After following these steps, your Docker setup will be ready to use Fortanix HSM encryption. + + + + + + ### Prerequisites + + - An [activated AWS CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) with at least 1 HSM device. + - A [HSM user with the `Crypto User` role](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html). In this guide we are using a user with the username `testUser` and the password `testPassword`. + + + + + + Before using the CloudHSM client, it must be configured properly so Infisical can use it for cryptographic operations. + + + **1. Download the AWS CloudHSM client** + + You can download the AWS CloudHSM client from [the AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/pkcs11-library-install.html). + + + Note that the AWS CloudHSM client is only available for Linux and Windows. + If you're on a different operating system, you'll need to access a Linux machine to configure the client, such as an AWS EC2 Debian instance. + + + **2. Configure the CloudHSM client** + + After installing the CloudHSM client, you should see all related files in the `/opt/cloudhsm/` directory on your machine. + + You need to run the `configure-pkcs11` binary which will configure the client to connect with your AWS CloudHSM cluster. Depending on if you have multiple HSM's inside your cluster, you'll need to run the command with different arguments. Below you'll find the appropriate command for your use case: + + + + + + ```bash + sudo /opt/cloudhsm/bin/configure-pkcs11 -a --disable-key-availability-check + ``` + + + To use a single HSM, you must first manage client key durability settings by setting `disable_key_availability_check` to true by passing the `--disable-key-availability-check` flag. For more information read the [Key Synchronization](https://docs.aws.amazon.com/cloudhsm/latest/userguide/manage-key-sync.html) section in the AWS CloudHSM documentation. + + + + + ```bash + sudo /opt/cloudhsm/bin/configure-pkcs11 -a ... --disable-key-availability-check + ``` + + + + At this point you should have: + 1. [Activated the CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) + 2. [Created a Crypto User HSM user](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html) + 3. Downloaded and configured the CloudHSM client as described in the previous steps. + + **3. Download the configured HSM client files** + + After configuring the CloudHSM client, you should notice that the PKCS11 configuration file has been updated to include the HSM's ENI IP address. You can find this file in the `/opt/cloudhsm/etc/cloudhsm-pkcs11.cfg` directory, and it should look like this: + + ```json cloudhsm-pkcs11.cfg + { + "clusters": [ + { + "type": "hsm1", + "cluster": { + // Your issuing CA certificate. + // As per AWS documentation, this defaults to `/opt/cloudhsm/etc/customerCA.crt`. + "hsm_ca_file": "/opt/cloudhsm/etc/customerCA.crt", + "servers": [ + { + "hostname": "", + "port": 2223, + "enable": true + }, + { + "hostname": "", + "port": 2223, + "enable": true + } + ], + // Only relevant if you passed the --disable-key-availability-check flag + "options": { + "disable_key_availability_check": true + } + } + } + ], + "logging": { + "log_type": "file", + "log_file": "/opt/cloudhsm/run/cloudhsm-pkcs11.log", + "log_level": "info", + "log_interval": "daily" + } + } + ``` + + Save the entire `/opt/cloudhsm` folder, as you will need to mount this to your Infisical Docker container in the later steps. In this guide we will be saving all the files from the folder as `/etc/cloudhsm` and mounting it to the `/etc/cloudhsm` directory in the Docker container. + + + + + On the same machine that you configured the CloudHSM client, you can use `pkcs11-tool` to find the HSM slot number and to verify that the client is working correctly. + + First, install the `pkcs11-tool` package: + + ```bash + sudo apt-get install opensc -y + ``` + + Then, run the following command to find the HSM slot number: + + ```bash + pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so --list-slots --login + ``` + + It'll prompt you to log in with your PIN, which is your username and password separated by a colon. Example: `testUser:testPassword`. + + This will output the HSM slot number like so: + + ```bash + ubuntu@ec-2:~$ pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so --list-slots + Available slots: + Slot 0 (0x2000000000000001): hsm1 + token label : hsm1 + token manufacturer : Marvell Semiconductors, Inc. + token model : LS2 + token flags : login required, rng, token initialized + hardware version : 66.48 + firmware version : 10.2 + serial num : + pin min/max : 8/32 + ``` + + In this case we see that the HSM has a slot in the position of `0`. This slot number will be used in the later steps to set the `HSM_SLOT` environment variable. + + + + When you initialized your HSM, you were prompted to download the cluster CSR and sign it. + In order to use the HSM with Infisical, you need to obtain the issuer CA certificate that was used to sign the cluster CSR. + + If you followed [the official AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/initialize-cluster.html), you should have a CA certificate called `customerCA.crt`. + + Save the CA certificate to a path, as this will need to be mounted as a Docker volume in the next step. For this example, we'll save it to `/aws-files/customerCA.crt`. + + + + Running Docker with HSM encryption requires setting the HSM-related environment variables as mentioned previously in the [HSM setup instructions](#setup-instructions). You can set these environment variables in your Docker run command. + + We are setting the environment variables for Docker via the command line in this example, but you can also pass in a `.env` file to set these environment variables. + + + If no key is found with the provided key label, the HSM will create a new key with the provided label. + Infisical depends on an AES and HMAC key to be present in the HSM. If these keys are not present, Infisical will create them. The AES key label will be the value of the `HSM_KEY_LABEL` environment variable, and the HMAC key label will be the value of the `HSM_KEY_LABEL` environment variable with the suffix `_HMAC`. + + + ```bash + docker run -p 80:8080 \ + + # Mount the HSM client files to "/opt/cloudhsm" + -v /etc/cloudhsm:/opt/cloudhsm \ + # Mount the issuer CA certificate to "/opt/cloudhsm/etc/customerCA.crt" + -v /aws-files/customerCA.crt:/opt/cloudhsm/etc/customerCA.crt \ + + # Set the HSM library path to whats expected within Docker (/opt/cloudhsm/lib/libcloudhsm_pkcs11.so) + -e HSM_LIB_PATH="/opt/cloudhsm/lib/libcloudhsm_pkcs11.so" \ + # Set the HSM PIN to the username and password of the HSM user, separated by a colon + -e HSM_PIN=CryptoUserUsername:CryptoUserPassword \ + # Set the HSM slot number to the slot number of the HSM device as found in the previous step + -e HSM_SLOT= \ + # Set the HSM key label to a label that will be used to identify the encryption key in the HSM. This key label does not need to exist before hand. + -e HSM_KEY_LABEL=infisical-crypto-key \ + + # The rest of your environment variables ... + # -e ... + infisical/infisical: # Replace with the version you want to use + ``` + + We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). + + + + After following these steps, your Docker setup will be ready to use HSM encryption. + + @@ -326,8 +511,9 @@ Enabling HSM encryption has a set of key benefits: + - This is only supported on helm chart version `1.4.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. + This is only supported on helm chart version `1.7.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. @@ -591,13 +777,11 @@ Enabling HSM encryption has a set of key benefits: After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. - We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with HSM support. - ```yaml # ... The rest of the values.yaml file ... image: - repository: infisical/infisical-fips # Very important: Must use "infisical/infisical-fips" + repository: infisical/infisical tag: "v0.117.1-postgres" pullPolicy: IfNotPresent @@ -757,13 +941,13 @@ Enabling HSM encryption has a set of key benefits: - Update your Helm values to use the FIPS-compliant image and mount the Fortanix HSM files: + Update your Helm values to mount the Fortanix HSM files: ```yaml # ... The rest of the values.yaml file ... image: - repository: infisical/infisical-fips # Must use "infisical/infisical-fips" + repository: infisical/infisical tag: "v0.117.1-postgres" pullPolicy: IfNotPresent @@ -800,6 +984,493 @@ Enabling HSM encryption has a set of key benefits: After following these steps, your Kubernetes setup will be ready to use Fortanix HSM encryption. + + + + ### Prerequisites + + - An [activated AWS CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) with at least 1 HSM device. + - A [HSM user with the `Crypto User` role](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html). In this guide we are using a user with the username `testUser` and the password `testPassword`. + - A Kubernetes cluster + + + AWS CloudHSM is supported on helm chart version `1.7.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. + + + + + + + If you're using AWS EKS, you need to specify a storage class for the PVC and ensure that the EBS CSI Driver is installed and running. + + By default, EKS exposes `gp2` as the default storage class. Below are the steps required for setting the default storage class and ensuring the EBS CSI Driver is installed and running: + + + + + + Enable OIDC authentication for the EKS cluster: + ```bash + eksctl utils associate-iam-oidc-provider \ + --region \ + --cluster \ + --approve + ``` + + * Replace `` with your AWS region. + * Replace `` with your cluster name. + + + + + + 1. Check if EBS CSI Driver is installed and running by running the following command: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ``` + + If you see no pods, you need to install the EBS CSI Driver as seen in the next step. + + + + Create a new IAM service account for the EBS CSI Driver: + + ```bash + eksctl create iamserviceaccount \ + --name ebs-csi-controller-sa \ + --namespace kube-system \ + --region \ + --cluster \ + --attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy \ + --approve \ + --role-name AmazonEKS_EBS_CSI_DriverRole + ``` + + * Replace `` with your cluster name. + * Replace `` with your AWS region. + + Install the EBS CSI Driver: + + ```bash + eksctl create addon \ + --name aws-ebs-csi-driver \ + --cluster \ + --region \ + --service-account-role-arn arn:aws:iam:::role/AmazonEKS_EBS_CSI_DriverRole \ + --force + ``` + + * Replace `` with your cluster name. + * Replace `` with your AWS region. + * Replace `` with your actual account ID. Can be obtained by running `aws sts get-caller-identity --query Account --output text`. + + + + Verify the EBS CSI Driver is installed and running by running the following command: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ``` + + You should see an output like this: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ebs-csi-controller-6b6bbf996-rvf8r 6/6 Running 0 21s + ebs-csi-controller-6b6bbf996-vk4ng 6/6 Running 0 21s + ebs-csi-node-c6vbb 3/3 Running 0 21s + ebs-csi-node-s9zlr 3/3 Running 0 21s + ``` + + + + You can find the enabled storage class by running the following command: + + ```bash + kubectl get storageclass + ``` + + You should see an output like this: + + ```bash + $ kubectl get storageclass + + NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE + gp2 kubernetes.io/aws-ebs Delete WaitForFirstConsumer false 65m + ``` + + In this case, the enabled storage class is `gp2`. + + + + You can set the default PVC storage class by patching the storage class with the following command: + + ```bash + kubectl patch storageclass gp2 -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' + ``` + + This will set the `gp2` storage class as the default storage class. + + + Now when you run `kubectl get storageclass`, you should see that `gp2` is the default storage class. + + ```bash + $ kubectl get storageclass + + NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE + gp2 (default) kubernetes.io/aws-ebs Delete WaitForFirstConsumer false 68m + ``` + + Notice the `(default)` next to the `gp2` storage class. + + + + + You need to create a Persistent Volume Claim (PVC) to mount the HSM client files to the Infisical deployment. + + ```bash + kubectl apply -f - < + + + + We need to configure the PVC to work with the CloudHSM, so Infisical can consume the HSM client files. + + **2.1. Start a shell in the PVC pod:** + + This will allow us to run commands directly within the setup pod. We'll use this to configure the CloudHSM client and to validate that it's working correctly. + + ```bash + kubectl exec -it cloudhsm-setup-pod -- /bin/sh + ``` + + **2.2. Install the necessary packages:** + + This will install the necessary packages to allow us to test and install the CloudHSM client. + + ```bash + apt-get update -y + apt-get install opensc telnet wget -y + ``` + + **2.3. Try to reach the HSM device:** + + We need to validate that we're able to reach the HSM device from within Kubernetes. You can use telnet to ping the HSM device like so: + + ```bash + telnet 2223 + ``` + + You should see an output like this: + ```bash + $ telnet 2223 + Trying ... + Connected to . + ``` + + If it gets stuck on `Trying ....`, you may have configured your HSM client's security group incorrectly. Make sure you configure the security group to allow traffic from EKS on port 2223-2225. + + **2.4. Install the AWS CloudHSM client:** + + The Infisical images run on Debian, so we need to install a Debian-compatible version of the AWS CloudHSM client. + + ```bash + wget https://s3.amazonaws.com/cloudhsmv2-software/CloudHsmClient/Jammy/cloudhsm-pkcs11_latest_u22.04_amd64.deb + apt-get install ./cloudhsm-pkcs11_latest_u22.04_amd64.deb -y + ``` + + **2.5. Configure the CloudHSM client:** + + After installing the CloudHSM client, you should see all related files in the `/opt/cloudhsm/` directory on the CloudHSM setup pod. + + You need to run the `configure-pkcs11` binary which will configure the client to connect with your AWS CloudHSM cluster. Depending on if you have multiple HSM's inside your cluster, you'll need to run the command with different arguments. Below you'll find the appropriate command for your use case: + + + + + ```bash + /opt/cloudhsm/bin/configure-pkcs11 -a --disable-key-availability-check + ``` + + + To use a single HSM, you must first manage client key durability settings by setting `disable_key_availability_check` to true by passing the `--disable-key-availability-check` flag. For more information read the [Key Synchronization](https://docs.aws.amazon.com/cloudhsm/latest/userguide/manage-key-sync.html) section in the AWS CloudHSM documentation. + + + + + ```bash + /opt/cloudhsm/bin/configure-pkcs11 -a ... --disable-key-availability-check + ``` + + + + **2.6. Verify the CloudHSM client is configured correctly:** + + You can verify the CloudHSM client is configured correctly by running the following command: + ```bash + cat /opt/cloudhsm/etc/cloudhsm-pkcs11.cfg + ``` + + You should see an output like this: + + ```json + { + "clusters": [ + { + "type": "hsm1", + "cluster": { + "hsm_ca_file": "/opt/cloudhsm/etc/customerCA.crt", + "servers": [ + { + "hostname": "172.31.39.155", + "port": 2223, + "enable": true + } + ], + "options": { + "disable_key_availability_check": true + } + } + } + ], + "logging": { + "log_type": "file", + "log_file": "/opt/cloudhsm/run/cloudhsm-pkcs11.log", + "log_level": "info", + "log_interval": "daily" + } + } + ``` + + **2.7. Exit the pod:** + + Exit the pod by running the following command: + ```bash + exit + ``` + + **2.8. Copy your issuer CA certificate to the PVC:** + + When you initialized your HSM, you were prompted to download the cluster CSR and sign it. + In order to use the HSM with Infisical, you need to obtain the issuer CA certificate that was used to sign the cluster CSR. + + If you followed [the official AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/initialize-cluster.html), you should have a CA certificate called `customerCA.crt`. + + Copy the CA certificate from your local machine to the setup pod: + + ```bash + kubectl cp /path/to/customerCA.crt cloudhsm-setup-pod:/opt/cloudhsm/etc/customerCA.crt + ``` + + Ensure that the file is at `/opt/cloudhsm/etc/customerCA.crt` inside the setup pod by running the following command: + ```bash + kubectl exec -it cloudhsm-setup-pod -- cat /opt/cloudhsm/etc/customerCA.crt + ``` + + **2.9. Test the HSM client:** + + Finally, after we're done configuring the HSM client, we need to test it to ensure that it's working correctly. + + First, start a new shell into the setup pod by running the same shell command as before: + ```bash + kubectl exec -it cloudhsm-setup-pod -- /bin/sh + ``` + + Next, try generating a random 32 bytes long string by running the following command: + ```bash + pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so \ + --login --pin : \ + --generate-random 32 | base64 + ``` + + You should see an output like this: + ```bash + Using slot 0 with a present token (0x2000000000000001) + av1dlhVEsssjpcTNS+ysGUoKWH6+/PCaEDIdal5oQc0= + ``` + + + Replace the `:` with your username and password combination of the Crypto user you have created that you want to use to perform cryptographic operations. + + In AWS CloudHSM, the PIN is always the username and password separated by a colon. + + + + + **2.10. Copy the configured client to the PVC:** + + Copy from the HSM files into the `/data` directory in the PVC, which is what will be mounted for the Infisical deployment. + ```bash + cp -r /opt/cloudhsm/. /data/ + ``` + + Verify the files were copied correctly by running the following command: + ```bash + ls -la /data/ + ``` + + You should see an output like this: + ```bash + drwxr-xr-x. 8 root root 4096 Oct 13 18:50 . + drwxr-xr-x. 1 root root 131 Oct 13 18:29 .. + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 bin + drwxr-xr-x. 3 root root 4096 Oct 13 18:50 doc + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 etc + drwxr-xr-x. 3 root root 4096 Oct 13 18:50 include + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 lib + drwxr-xr-t. 2 root root 4096 Oct 13 18:50 run + ``` + + **2.11. Set the correct permissions for the HSM client files:** + + ```bash + chmod -R 755 /data/ + ``` + + **2.12. Exit the pod:** + + Exit the pod by running the following command: + ```bash + exit + ``` + + **2.13. Delete the setup pod:** + + Delete the setup pod by running the following command: + ```bash + kubectl delete pod cloudhsm-setup-pod + ``` + + + + + Next we need to update the environment variables used for the deployment. If you followed the [setup instructions for Kubernetes deployments](/self-hosting/deployment-options/kubernetes-helm), you should have a Kubernetes secret called `infisical-secrets`. + We need to update the secret with the following environment variables: + + - `HSM_LIB_PATH` - The path to the CloudHSM PKCS#11 library _(mapped to `/opt/cloudhsm/lib/libcloudhsm_pkcs11.so`)_ + - `HSM_PIN` - The PIN for the HSM device, which is the username and password of your Crypto User separated by a colon (e.g., `testUser:testPassword`) + - `HSM_SLOT` - The slot number for the HSM device that you found in the previous step + - `HSM_KEY_LABEL` - The label for the HSM key. If no key is found with the provided key label, the HSM will create a new key with the provided label. + + The following is an example of the secret that you should update: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + # ... Other environment variables ... + HSM_LIB_PATH: "/opt/cloudhsm/lib/libcloudhsm_pkcs11.so" + HSM_PIN: "testUser:testPassword" # Replace with your actual Crypto User credentials + HSM_SLOT: "0" # Replace with your actual slot number + HSM_KEY_LABEL: "infisical-crypto-key" + ``` + + Save the file after updating the environment variables, and apply the secret changes + + ```bash + kubectl apply -f ./secret-file-name.yaml + ``` + + + + After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. + + ```yaml + # ... The rest of the values.yaml file ... + infisical: + image: + repository: infisical/infisical + tag: "v0.151.0-nightly-20251013.1" + pullPolicy: IfNotPresent + + extraVolumeMounts: + - name: cloudhsm-data + mountPath: /opt/cloudhsm # The path we will mount the HSM client files to + + extraVolumes: + - name: cloudhsm-data + persistentVolumeClaim: + claimName: cloudhsm-data-pvc # The PVC we created in the previous step + + # ... The rest of the values.yaml file ... + ``` + + + Make sure to set the `tag` to **`v0.151.0-nightly-20251013.1` or above**, as this is the minimum Infisical version that supports AWS CloudHSM. + + + + Ensure that the configuration file at `/opt/cloudhsm/etc/cloudhsm-pkcs11.cfg` references the correct path for the issuer CA certificate (`/opt/cloudhsm/etc/customerCA.crt`). This should already be configured correctly if you followed the previous steps. + + + + + + After updating the values.yaml file, you need to upgrade the Helm chart in order for the changes to take effect. + + ```bash + helm repo update + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + + After upgrading the Helm chart, you need to restart the deployment in order for the changes to take effect. + + ```bash + kubectl rollout restart deployment/infisical-infisical-standalone-infisical + ``` + + + After following these steps, your Kubernetes setup will be ready to use AWS CloudHSM encryption. + 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 0000000000..895da4d773 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 0000000000..8edc62261f 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 0000000000..69fe3b2b3e 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 0000000000..e03289a65f 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 0000000000..badc5d8c5f 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 0000000000..50ac89c279 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 0000000000..18e66a5a53 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-profile.png differ diff --git a/docs/images/platform/folder/replicate-secrets-modal.png b/docs/images/platform/folder/replicate-secrets-modal.png new file mode 100644 index 0000000000..eb9eeebf9e Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets-modal.png differ diff --git a/docs/images/platform/folder/replicate-secrets-result.png b/docs/images/platform/folder/replicate-secrets-result.png new file mode 100644 index 0000000000..473ad38d03 Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets-result.png differ diff --git a/docs/images/platform/folder/replicate-secrets.png b/docs/images/platform/folder/replicate-secrets.png new file mode 100644 index 0000000000..77e36d62c7 Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets.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 0000000000..a10f3a437f 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 0000000000..3e85f08794 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 0000000000..735471a7a6 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 0000000000..3588f88db3 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 0000000000..0c83e0a19c 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 0000000000..2d617a8b0f 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 0000000000..5bffb0ba95 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 0000000000..67ce552706 --- /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 0000000000..c93abb94a1 --- /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 fd98fac412..c1c51d4529 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/self-hosting/guides/replication.mdx b/docs/self-hosting/guides/replication.mdx new file mode 100644 index 0000000000..4767da6faf --- /dev/null +++ b/docs/self-hosting/guides/replication.mdx @@ -0,0 +1,162 @@ +--- + +title: "Replication" +description: "Learn how Infisical supports multi-region replication" + +--- + + + Infisical replication is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +Multi-region replication is available in Infisical Enterprise to support globally distributed deployments. Understanding the architecture, use cases, and operational considerations is essential before implementing this feature in production environments. + +Infisical uses a primary/secondary (1:N) architecture with asynchronous PostgreSQL replication. This design prioritizes high availability and minimal read latency for applications deployed across multiple geographic regions. + +## Use cases + +- **Multi-Region Deployments**: Serving secrets to applications distributed across continents from a single region introduces unacceptable latency. A centralized deployment also creates a single point of failure: regional outages can render secrets inaccessible globally, and network connectivity issues impact availability. + +- **Geographic Data Locality**: Global organizations need to minimize the time it takes for applications to retrieve secrets and configurations. Regional replicas enable applications to fetch data from nearby instances rather than making cross-continental requests. + +- **Disaster Recovery**: Organizations need resilience against primary region failures. Secondary regions with read replicas can be promoted to primary status when needed, maintaining operations during outages or disasters. + +## Design Goals + +In order to address the common use cases, the implementation reflects several core goals: + +- **Optimized Read Performance**: Applications need fast access to secrets regardless of their location. Regional instances use Redis for aggressive caching and read from local PostgreSQL replicas, eliminating cross-region round trips for most read operations. + +- **Conflict-Free Architecture**: All mutations flow through the primary instance exclusively. This prevents write conflicts and split-brain scenarios that plague multi-master systems. The trade-off ensures data integrity without requiring conflict resolution strategies. + +- **Zero Client Changes**: Existing Infisical integrations, SDKs, and CLI tools work without modification. Regional instances route write operations to the primary while handling reads locally. Authentication tokens and API keys function identically across all instances. + +- **Operational Simplicity**: Deploying additional regions requires minimal configuration. PostgreSQL handles replication complexity, and the stateless application tier scales horizontally without coordination overhead. + +# Architecture + +Infisical distinguishes between _primary_ and _secondary_ instances. The primary holds write authority and is the sole instance permitted to modify the PostgreSQL database. Secondary instances handle read traffic locally and proxy write operations to the primary. + +## Infrastructure components + +Two data stores form Infisical's persistence layer: + +- **PostgreSQL** maintains the authoritative dataset including secrets with their version history, authentication credentials, user identities, project configurations, access policies, audit trails, and integration settings. All persistent state lives in PostgreSQL. + +- **Redis** accelerates read operations through caching and manages asynchronous job queues. Each regional deployment maintains an independent Redis instance optimized for local access patterns. + +The Infisical application servers are stateless and therefore hold no persistent data internally. This design simplifies regional deployment and horizontal scaling. + + + + + A primary deployment consists of three core components: + + - **Application Servers**: Process all API requests directly, handling both read and write operations without forwarding + - **PostgreSQL Primary Database**: Accepts read and write queries, serving as the authoritative source of truth + - **Redis Cache**: Stores frequently accessed data and executes all background jobs including secret synchronization, scheduled tasks, and audit log processing + + + + Each secondary deployment mirrors the primary structure with key differences: + + - **Application Servers**: Service read requests from local infrastructure but forward any write requests to the primary region + - **PostgreSQL Read Replica**: Continuously streams changes from the primary database via PostgreSQL replication + - **PostgreSQL Primary Database**: Connection string to the primary database for write forwarding + - **Redis Cache**: Maintains a local cache but processes only audit logs (other background jobs remain disabled) + + Configuring a secondary region requires four main environment variables: + + 1. `INFISICAL_PRIMARY_INSTANCE_URL`: The primary region's Infisical API endpoint + 2. Postgres primary instance connection details. View related [environment variables](/self-hosting/configuration/envars#postgresql). + 3. Postgres read replica connection details. View related [environment variables](/self-hosting/configuration/envars#postgresql). + 4. Redis connection details. View related [environment variables](/self-hosting/configuration/envars#redis). + + + + +## How requests are processed + +When a client sends a read request to a secondary instance, the application first checks the local Redis cache for the requested data. If the data exists in cache, it's returned immediately to the client. Otherwise, the application queries the local PostgreSQL read replica, caches the result in Redis for future requests, and returns the response to the client. + +Write operations follow a different path. When a secondary receives a write request, it forwards the complete request to the primary instance URL. The primary processes the mutation against the authoritative database and returns a response, which the secondary then forwards back to the client. PostgreSQL subsequently streams these changes to all replicas asynchronously. + +Operations against the primary instance are more straightforward, as both reads and writes execute directly against local infrastructure without any forwarding. + +## Replication mechanism + +PostgreSQL streaming replication handles all data synchronization. When transactions commit on the primary, changes are written to the write-ahead log (WAL) and streamed to all configured replicas, which apply the entries to maintain consistency. Replication lag typically remains under one second. + +This approach replicates all data stored in PostgreSQL: secrets and their version histories, user accounts and permissions, authentication tokens, project configurations, access policies, audit logs, integration settings, and all other application metadata. Replicas are eventually consistent. This means that all replicas eventually converge to the same state, typically under 1 second. The application layer remains unaware of replication mechanics and operates identically across all instances. + +## Caching behavior + +Redis caches are regional and independent (no coordination occurs between instances): + +- Secondary instances populate caches on demand from read requests +- Cache hits serve data without touching PostgreSQL +- Cache misses fetch from the local replica and populate the cache +- Each region maintains its own hot dataset based on local access patterns + +Secrets use versioned caching. When a secret changes, its version identifier changes, causing automatic cache misses. This ensures subsequent reads fetch the updated value from PostgreSQL without requiring active cache invalidation. + +# Technical Details + +Understanding the implementation details can help evaluate whether Infisical's replication characteristics align with your requirements. +The following sections provide deeper insight into performance behavior, failure modes, and the underlying mechanisms that drive the replication system. + +### PostgreSQL streaming replication + +Infisical relies on PostgreSQL's native replication, which provides: + +- **Asynchronous operation**: The primary commits transactions immediately without waiting for replicas to confirm receipt. Replicas receive and apply changes continuously with typical lag measured in milliseconds to low seconds, depending on network conditions and write volume. + +- **Binary-level consistency**: Replication occurs at the storage layer using write-ahead logs, guaranteeing replicas are byte-for-byte identical to the primary at the block level. + +- **Promotion capability**: Read replicas can be promoted to primary during disaster recovery. Promotion requires updating Infisical configuration to designate the promoted instance as primary and reconfiguring other secondaries. + +Consult PostgreSQL's official documentation for replication setup instructions specific to your hosting environment (RDS, Cloud SQL, self-managed, etc.). + +### Version management + +All Infisical instances must run identical versions (mixing versions risks database schema mismatches or incompatible API behavior). Database migrations execute only on the primary and replicate to secondaries through standard PostgreSQL mechanisms. + +During upgrades: +1. Upgrade the primary instance (migrations run automatically) +2. Upgrade secondary instances to match +3. All instances can continue running during the upgrade process since database migrations don't immediately drop tables/columns + +### Request proxying + +When a secondary receives a mutation request (POST, PUT, PATCH, DELETE), it functions as a transparent proxy: + +1. Preserve the original request completely (headers, authentication context, request body) +2. Forward to the primary instance URL specified in configuration +3. Primary processes the request as a direct client request +4. Return the primary's response unmodified to the client + +### Cache management + +Infisical uses versioned caching rather than active invalidation: + +1. Secrets and other cached entities include version identifiers +2. When data mutates, its version changes in the database +3. Cache lookups include the version in the cache key +4. Version changes cause automatic cache misses +5. Cache misses fetch updated data from PostgreSQL +6. Fresh data populates the cache with the new version + +This strategy ensures correctness without requiring cross-region cache invalidation protocols. + +### Background job processing + +Secondary instances run with restricted background job capabilities: + +**Active**: Audit log processing +**Disabled**: Secret synchronization to third-party systems, scheduled tasks, cron jobs, time-triggered operations + +Limiting background jobs to the primary prevents duplicate processing and ensures integrations execute once. + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 951a643dd0..cfc65d1fd6 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 d32f5ac040..3598bf68ea 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/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index fdfefcf93b..de3b99f11a 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -270,7 +270,7 @@ "description": "This page shows the members of the selected project, and allows you to modify their permissions." }, "org": { - "title": "Organization Settings", + "title": "Settings", "description": "Manage members of your organization. These users could afterwards be formed into projects." }, "personal": { @@ -290,7 +290,7 @@ } }, "project": { - "title": "Project Settings", + "title": "Settings", "description": "These settings only apply to the currently selected Project.", "danger-zone": "Danger Zone", "delete-project": "Delete Project", diff --git a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx index 7257b3d04e..ebbe34d758 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 0000000000..90409da4f0 --- /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 b2186fed18..61ba543695 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 4fe457179e..f66cadaeae 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 0000000000..a359359f52 --- /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 d6c34fb87b..44d35cd2c5 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 0000000000..011f835b7c --- /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 f4616c711d..5ebc381847 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/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index 1c2891f420..3c38926af8 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -17,6 +17,7 @@ import { } from "@app/components/v2"; import { useProject } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; +import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { slugSchema } from "@app/lib/schemas"; export const secretTagsColors = [ @@ -85,6 +86,8 @@ const isValidHexColor = (hexColor: string) => { type Props = { isOpen?: boolean; onToggle: (isOpen: boolean) => void; + append: (data: WsTag) => void; + currentSecret?: SecretV3RawSanitized; }; const createTagSchema = z.object({ @@ -100,7 +103,7 @@ type TagColor = { name: string; }; -export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => { +export const CreateTagModal = ({ isOpen, onToggle, append, currentSecret }: Props): JSX.Element => { const { control, reset, @@ -128,11 +131,12 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => { const onFormSubmit = async ({ slug, color }: FormData) => { try { - await createWsTag({ + const data = await createWsTag({ projectId, tagColor: color, tagSlug: slug }); + append(data); onToggle(false); reset(); createNotification({ @@ -151,8 +155,12 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => { return (
{ isDisabled={isSubmitting} isLoading={isSubmitting} > - Create + {currentSecret ? "Create and Add" : "Create"} + + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx index 5774e7707e..f771579457 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/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx index 11628ce984..62ae29a1d3 100644 --- a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx @@ -16,7 +16,8 @@ export const AuditLogsPage = () => {
diff --git a/frontend/src/pages/organization/BillingPage/BillingPage.tsx b/frontend/src/pages/organization/BillingPage/BillingPage.tsx index e49314f131..3fb473f2ca 100644 --- a/frontend/src/pages/organization/BillingPage/BillingPage.tsx +++ b/frontend/src/pages/organization/BillingPage/BillingPage.tsx @@ -18,8 +18,9 @@ export const BillingPage = () => {
-
+
diff --git a/frontend/src/pages/organization/BillingPage/components/BillingTabGroup/BillingTabGroup.tsx b/frontend/src/pages/organization/BillingPage/components/BillingTabGroup/BillingTabGroup.tsx index 64d80d332c..7cd567b234 100644 --- a/frontend/src/pages/organization/BillingPage/components/BillingTabGroup/BillingTabGroup.tsx +++ b/frontend/src/pages/organization/BillingPage/components/BillingTabGroup/BillingTabGroup.tsx @@ -25,7 +25,9 @@ export const BillingTabGroup = withPermission( {tabsFiltered.map((tab) => ( - {tab.name} + + {tab.name} + ))} diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index da7643c50d..f0bd249000 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -83,7 +83,7 @@ const Page = () => {
{data && (
- +
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 446e54885d..adf6dfce89 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -75,7 +75,7 @@ const Page = () => {
{data && (
- +
diff --git a/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx b/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx index 660e4b26dc..e7f3937d55 100644 --- a/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx +++ b/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx @@ -14,6 +14,7 @@ export const NetworkingPage = () => {
diff --git a/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx b/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx index c59d64e1bb..956a4d1ed5 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx @@ -22,7 +22,7 @@ export const NetworkingTabGroup = () => { {tabs.map((tab) => ( - + {tab.name} ))} diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index 2e4b43cbe1..92654c1e78 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -65,7 +65,8 @@ export const ProjectsPage = () => {
diff --git a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx index ac67f0d54d..8b25247be2 100644 --- a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx @@ -81,6 +81,7 @@ export const Page = () => { {data && (
diff --git a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx index a026b86f3b..ec3885f5bc 100644 --- a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx @@ -22,6 +22,7 @@ export const SecretSharingPage = () => {
diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx index ae1aa3772c..6ee73a03df 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx @@ -21,7 +21,7 @@ export const SecretSharingSettingsPage = withPermission(
- +
diff --git a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx index 263672313a..52fc99f53d 100644 --- a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx @@ -15,7 +15,7 @@ export const SettingsPage = () => {
- +
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 42a8e58395..ad867238bb 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -58,7 +58,7 @@ export const OrgTabGroup = () => { {tabs.map((tab) => ( - + {tab.name} ))} diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index 03a720b986..3153c5fd58 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -119,6 +119,7 @@ const Page = withPermission( {membership && (
{
- +
diff --git a/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx b/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx index e8238d8a99..13e9028e60 100644 --- a/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx @@ -24,6 +24,7 @@ export const PamResourcesPage = () => {
diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx index b82c6b6e7d..af5b85fc6f 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx @@ -23,6 +23,7 @@ const Page = () => { {session && (
diff --git a/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx b/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx index 03ca60a3b7..5265075643 100644 --- a/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx @@ -24,6 +24,7 @@ export const PamSessionPage = () => {
diff --git a/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx b/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx index 81a0a1d070..b8b23cebd7 100644 --- a/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx @@ -13,7 +13,7 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.project.title") })}
- + General diff --git a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx index 9da8770d22..c010fcc39c 100644 --- a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx +++ b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx @@ -40,6 +40,7 @@ const Page = () => {
diff --git a/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx b/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx index 84fb69b832..78665e6244 100644 --- a/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx +++ b/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx @@ -23,6 +23,7 @@ export const AppConnectionsPage = withProjectPermission(
{
diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index d6bda7ea10..409d78633d 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -34,7 +34,7 @@ const Page = () => {
{groupMembership ? (
- +
diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 993320e562..061d537b16 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -117,6 +117,7 @@ const Page = () => { {identityMembershipDetails ? ( <> diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 23b8299daf..717226cfcb 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -119,6 +119,7 @@ export const Page = () => { {membershipDetails ? ( <> { {data && (
diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/CommitDetailsTab/CommitDetailsTab.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/CommitDetailsTab/CommitDetailsTab.tsx index 7146958902..60f634289e 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/CommitDetailsTab/CommitDetailsTab.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/CommitDetailsTab/CommitDetailsTab.tsx @@ -260,6 +260,7 @@ export const CommitDetailsTab = ({ Commit History diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx index 89124e3b9c..a3a25b1cc8 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx @@ -307,6 +307,7 @@ export const RollbackPreviewTab = (): JSX.Element => {
diff --git a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx index 2dba8ad550..e456bbdf33 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx @@ -49,6 +49,7 @@ export const CommitsPage = () => {
diff --git a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/IntegrationsDetailsByIDPage.tsx b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/IntegrationsDetailsByIDPage.tsx index 98dc087eaa..0eaa7dfacc 100644 --- a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/IntegrationsDetailsByIDPage.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/IntegrationsDetailsByIDPage.tsx @@ -94,6 +94,7 @@ export const IntegrationDetailsByIDPage = () => { {integration ? (
diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx index 647af2039f..4e2a0a703e 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -48,6 +48,7 @@ export const IntegrationsListPage = () => {
diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx new file mode 100644 index 0000000000..0b5dad2479 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TLaravelForgeSync } from "@app/hooks/api/secretSyncs/types/laravel-forge-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TLaravelForgeSync; +}; + +export const LaravelForgeSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 009af9930e..1e4df73514 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -20,6 +20,7 @@ import { GitLabSyncDestinationCol } from "./GitLabSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { LaravelForgeSyncDestinationCol } from "./LaravelForgeSyncDestinationCol"; import { NetlifySyncDestinationCol } from "./NetlifySyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; @@ -97,6 +98,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Bitbucket: return ; + case SecretSync.LaravelForge: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index bb0246236b..c4a3f2a378 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -194,6 +194,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.workspaceSlug; secondaryText = destinationConfig.repositorySlug; break; + case SecretSync.LaravelForge: + primaryText = destinationConfig.siteName || destinationConfig.siteId; + secondaryText = destinationConfig.orgName || destinationConfig.orgSlug; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 860f161e9d..022b33f88b 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -914,6 +914,7 @@ export const OverviewPage = () => {
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index b0f9f76665..1a33bbca1e 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -82,6 +82,7 @@ type Props = { isImported: boolean; }[]; }[]; + isSecretPresent?: boolean; }; export const SecretEditRow = ({ @@ -101,7 +102,8 @@ export const SecretEditRow = ({ isRotatedSecret, importedBy, importedSecret, - isEmpty + isEmpty, + isSecretPresent }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "editSecret" @@ -113,20 +115,21 @@ export const SecretEditRow = ({ const [isFieldFocused, setIsFieldFocused] = useToggle(); - const fetchSecretValueParams = importedSecret - ? { - environment: importedSecret.environment, - secretPath: importedSecret.secretPath, - secretKey: importedSecret.secret?.key ?? "", - projectId: currentProject.id - } - : { - environment, - secretPath, - secretKey: secretName, - projectId: currentProject.id, - isOverride - }; + const fetchSecretValueParams = + importedSecret && !isSecretPresent + ? { + environment: importedSecret.environment, + secretPath: importedSecret.secretPath, + secretKey: importedSecret.secret?.key ?? "", + projectId: currentProject.id + } + : { + environment, + secretPath, + secretKey: secretName, + projectId: currentProject.id, + isOverride + }; // scott: only fetch value if secret exists, has non-empty value and user has permission const canFetchValue = Boolean(importedSecret ?? secretId) && !isEmpty && !secretValueHidden; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 2a2e1a76a5..2099d812df 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -284,6 +284,7 @@ export const SecretOverviewTableRow = ({ environment={slug} isRotatedSecret={secret?.isRotatedSecret} importedBy={importedBy} + isSecretPresent={Boolean(secret)} /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx index 77106a57c2..6813aac07f 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx @@ -40,6 +40,7 @@ export const SecretApprovalsPage = () => {
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 85b1e572a0..9a99208a5a 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -252,7 +252,7 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr secretApprovalRequestDetails.isReplicated )}
-

+

By{" "} {secretApprovalRequestDetails?.committerUser ? ( <> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 2760e86264..da31fcf093 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -762,6 +762,7 @@ const Page = () => { return (

diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index d31c83512a..e48a3c468c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -380,7 +380,7 @@ export const ActionBar = ({ } }; - // Replicate Folder Logic + // Replicate Secrets Logic const createSecretCount = Object.keys( (popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {} ).length; @@ -439,17 +439,22 @@ export const ActionBar = ({ const processBatches = async () => { await secretBatches.reduce(async (previous, batch) => { await previous; + try { + const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({ + secretPath: normalizedPath, + environment, + projectId, + keys: batch + }); - const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({ - secretPath: normalizedPath, - environment, - projectId, - keys: batch - }); - - batchSecrets.forEach((secret) => { - existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`); - }); + batchSecrets.forEach((secret) => { + existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`); + }); + } catch (error) { + if (!(error instanceof AxiosError && error.response?.status === 404)) { + throw error; + } + } }, Promise.resolve()); }; @@ -1050,7 +1055,7 @@ export const ActionBar = ({ className="h-10 text-left" isFullWidth > - Replicate Folder + Replicate Secrets )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx index d0a9ed8865..08fe0153cc 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ReplicateFolderFromBoard/ReplicateFolderFromBoard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faClone } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faBookOpen, faClone } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -226,8 +226,27 @@ export const ReplicateFolderFromBoard = ({ + Replicate Secrets + +
+ + Docs + +
+
+
+ } + subTitle="Copy folder contents from other locations into this context" >
@@ -235,7 +254,12 @@ export const ReplicateFolderFromBoard = ({ control={control} name="environment" render={({ field: { value, onChange } }) => ( - + ( - +
-
- ( - - - - )} - /> -
- { - setValue("secrets", []); - setShouldIncludeValues(isChecked as boolean); - }} + ( + - Include secret values - -
-
- - -
+ + + )} + /> +
+ { + setValue("secrets", []); + setShouldIncludeValues(isChecked as boolean); + }} + > + Include secret values + +
+
+ +
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx index 7fbbc94364..be492f4949 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx @@ -33,11 +33,15 @@ type TImportedSecrets = Array<{ }>; export const computeImportedSecretRows = ( - importedSecEnv: string, - importedSecPath: string, + secretImport: TSecretImport, importSecrets: TImportedSecrets = [], - secrets: SecretV3RawSanitized[] = [] + secrets: SecretV3RawSanitized[] = [], + replicatedFolder?: TSecretImport ) => { + const importedSecEnv = replicatedFolder?.importEnv.slug ?? secretImport.importEnv.slug; + const importedSecPath = replicatedFolder?.importPath ?? secretImport.importPath; + const overrideEnv = secretImport.isReserved ? secretImport.importEnv.slug : undefined; + const overridePath = secretImport.isReserved ? secretImport.importPath : undefined; const importedSecIndex = importSecrets.findIndex( ({ secretPath, environmentInfo }) => secretPath === importedSecPath && importedSecEnv === environmentInfo.slug @@ -73,13 +77,13 @@ export const computeImportedSecretRows = ( isEmpty?: boolean; }[] = []; - importedSec.secrets.forEach(({ key, value, env, path, isEmpty }) => { + importedSec.secrets.forEach(({ key, value, isEmpty }) => { if (!importedEntry[key]) { importedSecretEntries.push({ key, value, - environment: env, - secretPath: path, + environment: overrideEnv ?? importedSec.environmentInfo.slug, + secretPath: overridePath ?? importedSec.secretPath, overridden: overridenSec?.[key], isEmpty }); @@ -125,6 +129,12 @@ export const SecretImportListView = ({ const [items, setItems] = useState(secretImports ?? []); + const getImportReplicatedFolder = (importPath: string) => { + const cleanImportPath = importPath.replace("/__reserve_replication_", ""); + const replicatedFolder = items?.find(({ id }) => id === cleanImportPath); + return replicatedFolder; + }; + useEffect(() => { if (!isFetching) { setItems(secretImports ?? []); @@ -204,6 +214,9 @@ export const SecretImportListView = ({ {items?.map((item) => { // TODO(akhilmhdh): change this and pass this whole object instead of one by one + const replicatedFolder = item.isReserved + ? getImportReplicatedFolder(item.importPath) + : undefined; return ( void; tags: WsTag[]; - onCreateTag: () => void; + onCreateTag: (secret?: SecretV3RawSanitized) => void; environment: string; secretPath: string; onShareSecret: (sec: SecretV3RawSanitized) => void; @@ -731,7 +731,7 @@ export const SecretItem = memo( className="h-3 w-3" /> } - onClick={onCreateTag} + onClick={() => onCreateTag(secret)} > Create a tag @@ -924,7 +924,7 @@ export const SecretItem = memo( className="h-3 w-3" /> } - onClick={onCreateTag} + onClick={() => onCreateTag(secret)} > Create a tag diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index 4fc314bdd4..bd43e12711 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -465,6 +465,22 @@ export const SecretListView = ({ [environment, secretPath, isProtectedBranch, isBatchMode, projectId, addPendingChange] ); + // Function to append newly created tag to the current secret + const append = useCallback( + (newTag: WsTag) => { + const currentSecret = popUp.createTag.data as SecretV3RawSanitized; + if (!currentSecret) return; + + const updatedTags = [...(currentSecret.tags || []), { id: newTag.id, slug: newTag.slug }]; + + handleSaveSecret(currentSecret, { + ...currentSecret, + tags: updatedTags + }); + }, + [popUp.createTag.data, handleSaveSecret] + ); + const handleSecretDelete = useCallback(async () => { const { key, @@ -552,7 +568,13 @@ export const SecretListView = ({ ]); // for optimization on minimise re-rendering of secret items - const onCreateTag = useCallback(() => handlePopUpOpen("createTag"), []); + const onCreateTag = useCallback((secret?: SecretV3RawSanitized) => { + if (secret) { + handlePopUpOpen("createTag", secret); + } else { + handlePopUpOpen("createTag"); + } + }, []); const onDeleteSecret = useCallback( (sec: SecretV3RawSanitized) => handlePopUpOpen("deleteSecret", sec), [] @@ -640,6 +662,8 @@ export const SecretListView = ({ handlePopUpToggle("createTag", isOpen)} + append={append} + currentSecret={popUp.createTag.data} /> diff --git a/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx b/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx index 0b9263803b..f4b474b9ef 100644 --- a/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx +++ b/frontend/src/pages/secret-manager/SecretRotationPage/SecretRotationPage.tsx @@ -147,6 +147,7 @@ const Page = () => { return (
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx new file mode 100644 index 0000000000..7d78fd770e --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx @@ -0,0 +1,24 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TLaravelForgeSync } from "@app/hooks/api/secretSyncs/types/laravel-forge-sync"; + +type Props = { + secretSync: TLaravelForgeSync; +}; + +export const LaravelForgeSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return ( + <> + + {destinationConfig.orgName || destinationConfig.orgSlug} + + + {destinationConfig.serverName || destinationConfig.serverId} + + + {destinationConfig.siteName || destinationConfig.siteId} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 7ab07c8d1b..78e00d8faa 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -31,6 +31,7 @@ import { GitLabSyncDestinationSection } from "./GitLabSyncDestinationSection"; import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { LaravelForgeSyncDestinationSection } from "./LaravelForgeSyncDestinationSection"; import { NetlifySyncDestinationSection } from "./NetlifySyncDestinationSection"; import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection"; @@ -148,6 +149,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Bitbucket: DestinationComponents = ; break; + case SecretSync.LaravelForge: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 1d3f10188d..61d1d8ccb9 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -71,6 +71,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: case SecretSync.Bitbucket: + case SecretSync.LaravelForge: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx index ad037b1259..a977750134 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx @@ -42,6 +42,7 @@ export const SettingsPage = () => {
diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/SecretScanningDataSourcesPage.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/SecretScanningDataSourcesPage.tsx index d681c34af5..71ffd82862 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/SecretScanningDataSourcesPage.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/SecretScanningDataSourcesPage.tsx @@ -26,6 +26,7 @@ export const SecretScanningDataSourcesPage = () => {
diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/SecretScanningFindingsPage.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/SecretScanningFindingsPage.tsx index 1eddf5eeef..320d294288 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/SecretScanningFindingsPage.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/SecretScanningFindingsPage.tsx @@ -23,7 +23,11 @@ export const SecretScanningFindingsPage = () => {
- +
diff --git a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx index 07161c360b..84a885cffa 100644 --- a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx @@ -19,6 +19,7 @@ export const SettingsPage = () => {
diff --git a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx index 841bb119f1..e820e14886 100644 --- a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx @@ -17,7 +17,11 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.project.title") })}
- + General diff --git a/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx b/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx index 06567b1218..4f83255941 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx @@ -70,7 +70,7 @@ const Page = () => {
{data && (
- +
diff --git a/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx b/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx index 669e264042..1156de3f00 100644 --- a/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx +++ b/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx @@ -16,6 +16,7 @@ export const SshCasPage = () => {
diff --git a/frontend/src/pages/ssh/SshCertsPage/SshCertsPage.tsx b/frontend/src/pages/ssh/SshCertsPage/SshCertsPage.tsx index 617f480a35..0078bd4b2a 100644 --- a/frontend/src/pages/ssh/SshCertsPage/SshCertsPage.tsx +++ b/frontend/src/pages/ssh/SshCertsPage/SshCertsPage.tsx @@ -16,6 +16,7 @@ export const SshCertsPage = () => {
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx index 1605286a8f..8cbca68489 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx @@ -71,7 +71,7 @@ const Page = () => {
{data && (
- +
diff --git a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx index c6efa96707..7c83eff1d5 100644 --- a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx @@ -16,6 +16,7 @@ export const SshHostsPage = () => {