diff --git a/.env.example b/.env.example index 727686cb06..9c254a017b 100644 --- a/.env.example +++ b/.env.example @@ -64,5 +64,9 @@ CLIENT_SECRET_GITHUB_LOGIN= CLIENT_ID_GITLAB_LOGIN= CLIENT_SECRET_GITLAB_LOGIN= +CAPTCHA_SECRET= + +NEXT_PUBLIC_CAPTCHA_SITE_KEY= + OTEL_COLLECTOR_OTLP_URL= OTEL_TELEMETRY_COLLECTION_ENABLED= diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index 2086601a82..dadd6c8605 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -40,13 +40,14 @@ jobs: REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable JWT_AUTH_SECRET: something-random + ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - uses: actions/setup-go@v5 with: go-version: '1.21.5' - name: Wait for container to be stable and check logs run: | SECONDS=0 - HEALTHY=0 + r HEALTHY=0 while [ $SECONDS -lt 60 ]; do if docker ps | grep infisical-api | grep -q healthy; then echo "Container is healthy." @@ -73,4 +74,4 @@ jobs: run: | docker-compose -f "docker-compose.dev.yml" down docker stop infisical-api - docker remove infisical-api \ No newline at end of file + docker remove infisical-api diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 5c1f15ca3a..8ffe7e3dea 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,7 +1,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id -ARG SAML_ORG_SLUG=saml-org-slug-default +ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:20-alpine AS base @@ -36,8 +36,8 @@ ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION -ARG SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -113,9 +113,9 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ ARG INTERCOM_ID=intercom-id ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID -ARG SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ - BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ + BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY WORKDIR / diff --git a/README.md b/README.md index 80f754c029..a5ed5c6213 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ To set up and run Infisical locally, make sure you have Git and Docker installed Linux/macOS: ```console -git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.prod.yml up +git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker compose -f docker-compose.prod.yml up ``` Windows Command Prompt: ```console -git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.prod.yml up +git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker compose -f docker-compose.prod.yml up ``` Create an account at `http://localhost:80` diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 965ea4e319..05753995c3 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -1,4 +1,5 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { Lock } from "@app/lib/red-lock"; export const mockKeyStore = (): TKeyStoreFactory => { const store: Record = {}; @@ -27,7 +28,10 @@ export const mockKeyStore = (): TKeyStoreFactory => { return 1; }, acquireLock: () => { - throw new Error("Not implemented"); - } + return Promise.resolve({ + release: () => {} + }) as Promise; + }, + waitTillReady: async () => {} }; }; diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991fd..43984ecfac 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -35,6 +35,8 @@ const getZodPrimitiveType = (type: string) => { return "z.coerce.number()"; case "text": return "z.string()"; + case "bytea": + return "zodBuffer"; default: throw new Error(`Invalid type: ${type}`); } @@ -96,10 +98,15 @@ const main = async () => { const columnNames = Object.keys(columns); let schema = ""; + const zodImportSet = new Set(); for (let colNum = 0; colNum < columnNames.length; colNum++) { const columnName = columnNames[colNum]; const colInfo = columns[columnName]; let ztype = getZodPrimitiveType(colInfo.type); + if (["zodBuffer"].includes(ztype)) { + zodImportSet.add(ztype); + } + // don't put optional on id if (colInfo.defaultValue && columnName !== "id") { const { defaultValue } = colInfo; @@ -121,6 +128,8 @@ const main = async () => { .split("_") .reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, ""); + const zodImports = Array.from(zodImportSet); + // the insert and update are changed to zod input type to use default cases writeFileSync( path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`), @@ -131,6 +140,8 @@ const main = async () => { import { z } from "zod"; +${zodImports.length ? `import { ${zodImports.join(",")} } from \"@app/lib/zod\";` : ""} + import { TImmutableDBKeys } from "./models"; export const ${pascalCase}Schema = z.object({${schema}}); diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index ffebf920e3..117a74e765 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -98,6 +98,15 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TKmsKeys, + TKmsKeysInsert, + TKmsKeysUpdate, + TKmsKeyVersions, + TKmsKeyVersionsInsert, + TKmsKeyVersionsUpdate, + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate, TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate, @@ -176,6 +185,9 @@ import { TSecretImports, TSecretImportsInsert, TSecretImportsUpdate, + TSecretReferences, + TSecretReferencesInsert, + TSecretReferencesUpdate, TSecretRotationOutputs, TSecretRotationOutputsInsert, TSecretRotationOutputsUpdate, @@ -240,7 +252,6 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { TSecretReferences, TSecretReferencesInsert, TSecretReferencesUpdate } from "@app/db/schemas/secret-references"; declare module "knex/types/tables" { interface Tables { @@ -514,5 +525,13 @@ declare module "knex/types/tables" { TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionUpdate >; + // KMS service + [TableName.KmsServerRootConfig]: Knex.CompositeTableType< + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate + >; + [TableName.KmsKey]: Knex.CompositeTableType; + [TableName.KmsKeyVersion]: Knex.CompositeTableType; } } diff --git a/backend/src/db/migrations/20240603075514_kms.ts b/backend/src/db/migrations/20240603075514_kms.ts new file mode 100644 index 0000000000..3531682d5d --- /dev/null +++ b/backend/src/db/migrations/20240603075514_kms.ts @@ -0,0 +1,56 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.KmsServerRootConfig))) { + await knex.schema.createTable(TableName.KmsServerRootConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedRootKey").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + if (!(await knex.schema.hasTable(TableName.KmsKey))) { + await knex.schema.createTable(TableName.KmsKey, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.string("encryptionAlgorithm").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.string("description"); + t.boolean("isDisabled").defaultTo(false); + t.boolean("isReserved").defaultTo(true); + t.string("projectId"); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKey); + + if (!(await knex.schema.hasTable(TableName.KmsKeyVersion))) { + await knex.schema.createTable(TableName.KmsKeyVersion, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.integer("version").notNullable(); + t.uuid("kmsKeyId").notNullable(); + t.foreign("kmsKeyId").references("id").inTable(TableName.KmsKey).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKeyVersion); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.KmsServerRootConfig); + await dropOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + await knex.schema.dropTableIfExists(TableName.KmsKeyVersion); + await dropOnUpdateTrigger(knex, TableName.KmsKeyVersion); + + await knex.schema.dropTableIfExists(TableName.KmsKey); + await dropOnUpdateTrigger(knex, TableName.KmsKey); +} diff --git a/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts b/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts new file mode 100644 index 0000000000..66fa031821 --- /dev/null +++ b/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (!hasConsecutiveFailedPasswordAttempts) { + tb.integer("consecutiveFailedPasswordAttempts").defaultTo(0); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (hasConsecutiveFailedPasswordAttempts) { + tb.dropColumn("consecutiveFailedPasswordAttempts"); + } + }); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 517b92e741..1eaa86c871 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -30,6 +30,9 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./kms-key-versions"; +export * from "./kms-keys"; +export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; @@ -57,6 +60,7 @@ export * from "./secret-blind-indexes"; export * from "./secret-folder-versions"; export * from "./secret-folders"; export * from "./secret-imports"; +export * from "./secret-references"; export * from "./secret-rotation-outputs"; export * from "./secret-rotations"; export * from "./secret-scanning-git-risks"; diff --git a/backend/src/db/schemas/kms-key-versions.ts b/backend/src/db/schemas/kms-key-versions.ts new file mode 100644 index 0000000000..52a8069df7 --- /dev/null +++ b/backend/src/db/schemas/kms-key-versions.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeyVersionsSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + version: z.number(), + kmsKeyId: z.string().uuid() +}); + +export type TKmsKeyVersions = z.infer; +export type TKmsKeyVersionsInsert = Omit, TImmutableDBKeys>; +export type TKmsKeyVersionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts new file mode 100644 index 0000000000..503c270d9a --- /dev/null +++ b/backend/src/db/schemas/kms-keys.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeysSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + encryptionAlgorithm: z.string(), + version: z.number().default(1), + description: z.string().nullable().optional(), + isDisabled: z.boolean().default(false).nullable().optional(), + isReserved: z.boolean().default(true).nullable().optional(), + projectId: z.string().nullable().optional(), + orgId: z.string().uuid().nullable().optional() +}); + +export type TKmsKeys = z.infer; +export type TKmsKeysInsert = Omit, TImmutableDBKeys>; +export type TKmsKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts new file mode 100644 index 0000000000..d2c0edbc5e --- /dev/null +++ b/backend/src/db/schemas/kms-root-config.ts @@ -0,0 +1,19 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsRootConfigSchema = z.object({ + id: z.string().uuid(), + encryptedRootKey: zodBuffer +}); + +export type TKmsRootConfig = z.infer; +export type TKmsRootConfigInsert = Omit, TImmutableDBKeys>; +export type TKmsRootConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 170d886ecb..f9c8436dfd 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -81,7 +81,11 @@ export enum TableName { DynamicSecretLease = "dynamic_secret_leases", // junction tables with tags JnSecretTag = "secret_tag_junction", - SecretVersionTag = "secret_version_tag_junction" + SecretVersionTag = "secret_version_tag_junction", + // KMS Service + KmsServerRootConfig = "kms_root_config", + KmsKey = "kms_keys", + KmsKeyVersion = "kms_key_versions" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 9e0b9a3b51..5134f3ee60 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -25,7 +25,8 @@ export const UsersSchema = z.object({ isEmailVerified: z.boolean().default(false).nullable().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), isLocked: z.boolean().default(false).nullable().optional(), - temporaryLockDateEnd: z.date().nullable().optional() + temporaryLockDateEnd: z.date().nullable().optional(), + consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 6773c9486f..dd49bd0aeb 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -77,7 +77,7 @@ type TLdapConfigServiceFactoryDep = { >; userAliasDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; }; export type TLdapConfigServiceFactory = ReturnType; @@ -510,6 +510,7 @@ export const ldapConfigServiceFactory = ({ return newUserAlias; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const user = await userDAL.transaction(async (tx) => { const newUser = await userDAL.findOne({ id: userAlias.userId }, tx); diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 7dfd211e13..5d7b7ec3b9 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -50,7 +50,7 @@ type TSamlConfigServiceFactoryDep = { orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; tokenService: Pick; smtpService: Pick; }; @@ -449,6 +449,7 @@ export const samlConfigServiceFactory = ({ return newUser; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const isUserCompleted = Boolean(user.isAccepted); const providerAuthToken = jwt.sign( diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 9a084c6d71..8f8d5dbc95 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -391,7 +391,7 @@ export const scimServiceFactory = ({ ); } } - + await licenseService.updateSubscriptionOrgMemberCount(org.id); return { user, orgMembership }; }); diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 76cd6d5226..ce752a1e54 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -9,6 +9,15 @@ export enum KeyStorePrefixes { SecretReplication = "secret-replication-import-lock" } +type TWaitTillReady = { + key: string; + waitingCb?: () => void; + keyCheckCb: (val: string | null) => boolean; + waitIteration?: number; + delay?: number; + jitter?: number; +}; + export const keyStoreFactory = (redisUrl: string) => { const redis = new Redis(redisUrl); const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); @@ -29,6 +38,29 @@ export const keyStoreFactory = (redisUrl: string) => { const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + const waitTillReady = async ({ + key, + waitingCb, + keyCheckCb, + waitIteration = 10, + delay = 1000, + jitter = 200 + }: TWaitTillReady) => { + let attempts = 0; + let isReady = keyCheckCb(await getItem(key)); + while (!isReady) { + if (attempts > waitIteration) return; + // eslint-disable-next-line + await new Promise((resolve) => { + waitingCb?.(); + setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter))); + }); + attempts += 1; + // eslint-disable-next-line + isReady = keyCheckCb(await getItem(key, "wait_till_ready")); + } + }; + return { setItem, getItem, @@ -37,6 +69,7 @@ export const keyStoreFactory = (redisUrl: string) => { incrementBy, acquireLock(resources: string[], duration: number, settings?: Partial) { return redisLock.acquire(resources, duration, settings); - } + }, + waitTillReady }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 70f5ed608c..1637b266a8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -386,6 +386,8 @@ export const SECRET_IMPORTS = { environment: "The slug of the environment to import into.", path: "The path to import into.", workspaceId: "The ID of the project you are working in.", + isReplication: + "When true, secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately.", import: { environment: "The slug of the environment to import from.", path: "The path to import from." @@ -661,6 +663,7 @@ export const INTEGRATION = { targetServiceId: "The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", owner: "External integration providers service entity owner. Used in Github.", + url: "The self-hosted URL of the platform to integrate with", path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault", region: "AWS region to sync secrets to.", scope: "Scope of the provider. Used by Github, Qovery", @@ -673,7 +676,10 @@ export const INTEGRATION = { secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", kmsKeyId: "The ID of the encryption key from AWS KMS.", - shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store." + shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store.", + shouldMaskSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Masked'.", + shouldProtectSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Protected'.", + shouldEnableDelete: "The flag to enable deletion of secrets" } }, UPDATE: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 41ae2c7887..502a22c4ee 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -39,7 +39,9 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, // smtp options SMTP_HOST: zpStr(z.string().optional()), - SMTP_SECURE: zodStrBool, + SMTP_IGNORE_TLS: zodStrBool.default("false"), + SMTP_REQUIRE_TLS: zodStrBool.default("true"), + SMTP_TLS_REJECT_UNAUTHORIZED: zodStrBool.default("true"), SMTP_PORT: z.coerce.number().default(587), SMTP_USERNAME: zpStr(z.string().optional()), SMTP_PASSWORD: zpStr(z.string().optional()), @@ -75,6 +77,7 @@ const envSchema = z .optional() .default(process.env.URL_GITLAB_LOGIN ?? GITLAB_URL) ), // fallback since URL_GITLAB_LOGIN has been renamed + DEFAULT_SAML_ORG_SLUG: zpStr(z.string().optional()).default(process.env.NEXT_PUBLIC_SAML_ORG_SLUG), // integration client secrets // heroku CLIENT_ID_HEROKU: zpStr(z.string().optional()), @@ -120,6 +123,7 @@ const envSchema = z .optional(), INFISICAL_CLOUD: zodStrBool.default("false"), MAINTENANCE_MODE: zodStrBool.default("false"), + CAPTCHA_SECRET: zpStr(z.string().optional()), OTEL_TELEMETRY_COLLECTION_ENABLED: zodStrBool.default("false"), OTEL_COLLECTOR_OTLP_URL: zpStr(z.string().optional()) }) @@ -133,7 +137,8 @@ const envSchema = z isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && - Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET) + Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG })); let envCfg: Readonly>; @@ -152,13 +157,20 @@ export const initEnvConfig = (logger: Logger) => { return envCfg; }; -export const formatSmtpConfig = () => ({ - host: envCfg.SMTP_HOST, - port: envCfg.SMTP_PORT, - auth: - envCfg.SMTP_USERNAME && envCfg.SMTP_PASSWORD - ? { user: envCfg.SMTP_USERNAME, pass: envCfg.SMTP_PASSWORD } - : undefined, - secure: envCfg.SMTP_SECURE, - from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>` -}); +export const formatSmtpConfig = () => { + return { + host: envCfg.SMTP_HOST, + port: envCfg.SMTP_PORT, + auth: + envCfg.SMTP_USERNAME && envCfg.SMTP_PASSWORD + ? { user: envCfg.SMTP_USERNAME, pass: envCfg.SMTP_PASSWORD } + : undefined, + secure: envCfg.SMTP_PORT === 465, + from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>`, + ignoreTLS: envCfg.SMTP_IGNORE_TLS, + requireTLS: envCfg.SMTP_REQUIRE_TLS, + tls: { + rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED + } + }; +}; diff --git a/backend/src/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts new file mode 100644 index 0000000000..7bc16b4700 --- /dev/null +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -0,0 +1,49 @@ +import crypto from "crypto"; + +import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; + +const getIvLength = () => { + return 12; +}; + +const getTagLength = () => { + return 16; +}; + +export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { + const IV_LENGTH = getIvLength(); + const TAG_LENGTH = getTagLength(); + + const encrypt = (text: Buffer, key: Buffer) => { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(type, key, iv); + + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + + // Get the authentication tag + const tag = cipher.getAuthTag(); + + // Concatenate IV, encrypted text, and tag into a single buffer + const ciphertextBlob = Buffer.concat([iv, encrypted, tag]); + return ciphertextBlob; + }; + + const decrypt = (ciphertextBlob: Buffer, key: Buffer) => { + // Extract the IV, encrypted text, and tag from the buffer + const iv = ciphertextBlob.subarray(0, IV_LENGTH); + const tag = ciphertextBlob.subarray(-TAG_LENGTH); + const encrypted = ciphertextBlob.subarray(IV_LENGTH, -TAG_LENGTH); + + const decipher = crypto.createDecipheriv(type, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted; + }; + + return { + encrypt, + decrypt + }; +}; diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts new file mode 100644 index 0000000000..41dbcf639c --- /dev/null +++ b/backend/src/lib/crypto/cipher/index.ts @@ -0,0 +1,2 @@ +export { symmetricCipherService } from "./cipher"; +export { SymmetricEncryption } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts new file mode 100644 index 0000000000..f490d6a663 --- /dev/null +++ b/backend/src/lib/crypto/cipher/types.ts @@ -0,0 +1,9 @@ +export enum SymmetricEncryption { + AES_GCM_256 = "aes-256-gcm", + AES_GCM_128 = "aes-128-gcm" +} + +export type TSymmetricEncryptionFns = { + encrypt: (text: Buffer, key: Buffer) => Buffer; + decrypt: (blob: Buffer, key: Buffer) => Buffer; +}; diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 16a7f42e7c..6af20862b2 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -11,6 +11,8 @@ import { getConfig } from "../config/env"; export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); +export const randomSecureBytes = (length = 32) => crypto.randomBytes(length); + export type TDecryptSymmetricInput = { ciphertext: string; iv: string; diff --git a/backend/src/lib/crypto/index.ts b/backend/src/lib/crypto/index.ts index db3d91fc81..cc6acfb805 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -9,7 +9,8 @@ export { encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair + generateAsymmetricKeyPair, + randomSecureBytes } from "./encryption"; export { decryptIntegrationAuths, diff --git a/backend/src/lib/zod/index.ts b/backend/src/lib/zod/index.ts index a3cded66b1..4d3fea8c7b 100644 --- a/backend/src/lib/zod/index.ts +++ b/backend/src/lib/zod/index.ts @@ -7,3 +7,7 @@ export const zpStr = (schema: T, opt: { stripNull: boolean if (typeof val !== "string") return val; return val.trim() || undefined; }, schema); + +export const zodBuffer = z.custom((data) => Buffer.isBuffer(data) || data instanceof Uint8Array, { + message: "Expected binary data (Buffer Or Uint8Array)" +}); diff --git a/backend/src/server/boot-strap-check.ts b/backend/src/server/boot-strap-check.ts index 381e575efa..ceaef59e7f 100644 --- a/backend/src/server/boot-strap-check.ts +++ b/backend/src/server/boot-strap-check.ts @@ -5,7 +5,6 @@ import { createTransport } from "nodemailer"; import { formatSmtpConfig, getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; -import { getTlsOption } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; type BootstrapOpt = { @@ -44,7 +43,7 @@ export const bootstrapCheck = async ({ db }: BootstrapOpt) => { console.info("Testing smtp connection"); const smtpCfg = formatSmtpConfig(); - await createTransport({ ...smtpCfg, ...getTlsOption(smtpCfg.host, smtpCfg.secure) }) + await createTransport(smtpCfg) .verify() .then(async () => { console.info("SMTP successfully connected"); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ed2d312545..00590386a1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -97,6 +97,9 @@ import { integrationDALFactory } from "@app/services/integration/integration-dal import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { kmsDALFactory } from "@app/services/kms/kms-dal"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -261,6 +264,9 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const kmsDAL = kmsDALFactory(db); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -269,6 +275,12 @@ export const registerRoutes = async ( projectDAL }); const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); + const kmsService = kmsServiceFactory({ + kmsRootConfigDAL, + keyStore, + kmsDAL + }); + const trustedIpService = trustedIpServiceFactory({ licenseService, projectDAL, @@ -823,6 +835,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); + await kmsService.startService(); // inject all services server.decorate("services", { @@ -906,7 +919,8 @@ export const registerRoutes = async ( emailConfigured: z.boolean().optional(), inviteOnlySignup: z.boolean().optional(), redisConfigured: z.boolean().optional(), - secretScanningConfigured: z.boolean().optional() + secretScanningConfigured: z.boolean().optional(), + samlDefaultOrgSlug: z.string().optional() }) } }, @@ -919,7 +933,8 @@ export const registerRoutes = async ( emailConfigured: cfg.isSmtpConfigured, inviteOnlySignup: Boolean(serverCfg.allowSignUp), redisConfigured: cfg.isRedisConfigured, - secretScanningConfigured: cfg.isSecretScanningConfigured + secretScanningConfigured: cfg.isSecretScanningConfigured, + samlDefaultOrgSlug: cfg.samlDefaultOrgSlug }; } }); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f23abc45bc..97a7f4d7a2 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -8,7 +8,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; +import { IntegrationMetadataSchema } from "@app/services/integration/integration-schema"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { @@ -42,39 +42,11 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService), targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId), owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner), + url: z.string().trim().optional().describe(INTEGRATION.CREATE.url), path: z.string().trim().optional().describe(INTEGRATION.CREATE.path), region: z.string().trim().optional().describe(INTEGRATION.CREATE.region), scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope), - metadata: z - .object({ - secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), - secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), - mappingBehavior: z - .nativeEnum(IntegrationMappingBehavior) - .optional() - .describe(INTEGRATION.CREATE.metadata.mappingBehavior), - shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), - secretGCPLabel: z - .object({ - labelName: z.string(), - labelValue: z.string() - }) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), - secretAWSTag: z - .array( - z.object({ - key: z.string(), - value: z.string() - }) - ) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), - shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) - }) - .default({}) + metadata: IntegrationMetadataSchema.default({}) }), response: { 200: z.object({ @@ -160,33 +132,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().describe(INTEGRATION.UPDATE.environment), - metadata: z - .object({ - secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), - secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), - mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), - shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), - secretGCPLabel: z - .object({ - labelName: z.string(), - labelValue: z.string() - }) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), - secretAWSTag: z - .array( - z.object({ - key: z.string(), - value: z.string() - }) - ) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), - shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) - }) - .optional() + metadata: IntegrationMetadataSchema.optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v1/secret-import-router.ts index 50311273c4..ca604e7382 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -30,7 +30,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) }), - isReplication: z.boolean().default(false) + isReplication: z.boolean().default(false).describe(SECRET_IMPORTS.CREATE.isReplication) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 900ad56d27..4c7df5612b 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -80,7 +80,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { body: z.object({ email: z.string().trim(), providerAuthToken: z.string().trim().optional(), - clientProof: z.string().trim() + clientProof: z.string().trim(), + captchaToken: z.string().trim().optional() }), response: { 200: z.discriminatedUnion("mfaEnabled", [ @@ -106,6 +107,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); const data = await server.services.login.loginExchangeClientProof({ + captchaToken: req.body.captchaToken, email: req.body.email, ip: req.realIp, userAgent, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index cbf43b2454..a136508e7f 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -176,12 +177,16 @@ export const authLoginServiceFactory = ({ clientProof, ip, userAgent, - providerAuthToken + providerAuthToken, + captchaToken }: TLoginClientProofDTO) => { + const appCfg = getConfig(); + const userEnc = await userDAL.findUserEncKeyByUsername({ username: email }); if (!userEnc) throw new Error("Failed to find user"); + const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); let authMethod = AuthMethod.EMAIL; @@ -196,6 +201,31 @@ export const authLoginServiceFactory = ({ } } + if ( + user.consecutiveFailedPasswordAttempts && + user.consecutiveFailedPasswordAttempts >= 10 && + Boolean(appCfg.CAPTCHA_SECRET) + ) { + if (!captchaToken) { + throw new BadRequestError({ + name: "Captcha Required", + message: "Accomplish the required captcha by logging in via Web" + }); + } + + // validate captcha token + const response = await request.postForm<{ success: boolean }>("https://api.hcaptcha.com/siteverify", { + response: captchaToken, + secret: appCfg.CAPTCHA_SECRET + }); + + if (!response.data.success) { + throw new BadRequestError({ + name: "Invalid Captcha" + }); + } + } + if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); const isValidClientProof = await srpCheckClientProof( userEnc.salt, @@ -204,15 +234,31 @@ export const authLoginServiceFactory = ({ userEnc.clientPublicKey, clientProof ); - if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); + + if (!isValidClientProof) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new Error("Failed to authenticate. Try again?"); + } await userDAL.updateUserEncryptionByUserId(userEnc.userId, { serverPrivateKey: null, clientPublicKey: null }); + + await userDAL.updateById(userEnc.userId, { + consecutiveFailedPasswordAttempts: 0 + }); + // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { - const user = await userDAL.findById(userEnc.userId); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); const mfaToken = jwt.sign( diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 37b90f548b..4f73ec9961 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -12,6 +12,7 @@ export type TLoginClientProofDTO = { providerAuthToken?: string; ip: string; userAgent: string; + captchaToken?: string; }; export type TVerifyMfaTokenDTO = { diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 74d881d266..02091d88cb 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -199,6 +199,7 @@ export const integrationAuthServiceFactory = ({ projectId, namespace, integration, + url, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, ...(integration === Integrations.GCP_SECRET_MANAGER diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 2aaf5d5f41..edc426327a 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -30,7 +30,8 @@ export enum Integrations { DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform", CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", - HASURA_CLOUD = "hasura-cloud" + HASURA_CLOUD = "hasura-cloud", + RUNDECK = "rundeck" } export enum IntegrationType { @@ -368,6 +369,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Rundeck", + slug: "rundeck", + image: "Rundeck.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" } ]; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 12c6910853..6351b4d824 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -31,6 +31,7 @@ import { logger } from "@app/lib/logger"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; +import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, @@ -1363,38 +1364,41 @@ const syncSecretsGitHub = async ({ } } - for await (const encryptedSecret of encryptedSecrets) { - if ( - !(encryptedSecret.name in secrets) && - !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && - !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) - ) { - switch (integration.scope) { - case GithubScope.Org: { - await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { - org: integration.owner as string, - secret_name: encryptedSecret.name - }); - break; - } - case GithubScope.Env: { - await octokit.request( - "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", - { - repository_id: Number(integration.appId), - environment_name: integration.targetEnvironmentId as string, + const metadata = IntegrationMetadataSchema.parse(integration.metadata); + if (metadata.shouldEnableDelete) { + for await (const encryptedSecret of encryptedSecrets) { + if ( + !(encryptedSecret.name in secrets) && + !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && + !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) + ) { + switch (integration.scope) { + case GithubScope.Org: { + await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, secret_name: encryptedSecret.name - } - ); - break; - } - default: { - await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: integration.owner as string, - repo: integration.app as string, - secret_name: encryptedSecret.name - }); - break; + }); + break; + } + case GithubScope.Env: { + await octokit.request( + "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: encryptedSecret.name + } + ); + break; + } + default: { + await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: encryptedSecret.name + }); + break; + } } } } @@ -1917,13 +1921,13 @@ const syncSecretsGitLab = async ({ return allEnvVariables; }; + const metadata = IntegrationMetadataSchema.parse(integration.metadata); const allEnvVariables = await getAllEnvVariables(integration?.appId as string, accessToken); const getSecretsRes: GitLabSecret[] = allEnvVariables .filter((secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment) .filter((gitLabSecret) => { let isValid = true; - const metadata = z.record(z.any()).parse(integration.metadata); if (metadata.secretPrefix && !gitLabSecret.key.startsWith(metadata.secretPrefix)) { isValid = false; } @@ -1943,8 +1947,8 @@ const syncSecretsGitLab = async ({ { key, value: secrets[key].value, - protected: false, - masked: false, + protected: Boolean(metadata.shouldProtectSecrets), + masked: Boolean(metadata.shouldMaskSecrets), raw: false, environment_scope: integration.targetEnvironment }, @@ -1961,7 +1965,9 @@ const syncSecretsGitLab = async ({ `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, { ...existingSecret, - value: secrets[existingSecret.key].value + value: secrets[existingSecret.key].value, + protected: Boolean(metadata.shouldProtectSecrets), + masked: Boolean(metadata.shouldMaskSecrets) }, { headers: { @@ -2750,6 +2756,20 @@ const syncSecretsCloudflarePages = async ({ } } ); + + const metadata = z.record(z.any()).parse(integration.metadata); + if (metadata.shouldAutoRedeploy) { + await request.post( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accessId}/pages/projects/${integration.app}/deployments`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } }; /** @@ -3355,6 +3375,82 @@ const syncSecretsHasuraCloud = async ({ } }; +/** Sync/push [secrets] to Rundeck + * @param {Object} obj + * @param {TIntegrations} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Rundeck integration + */ +const syncSecretsRundeck = async ({ + integration, + secrets, + accessToken +}: { + integration: TIntegrations; + secrets: Record; + accessToken: string; +}) => { + interface RundeckSecretResource { + name: string; + } + interface RundeckSecretsGetRes { + resources: RundeckSecretResource[]; + } + + let existingRundeckSecrets: string[] = []; + + try { + const listResult = await request.get( + `${integration.url}/api/44/storage/${integration.path}`, + { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + } + ); + + existingRundeckSecrets = listResult.data.resources.map((res) => res.name); + } catch (err) { + logger.info("No existing rundeck secrets"); + } + + try { + for await (const [key, value] of Object.entries(secrets)) { + if (existingRundeckSecrets.includes(key)) { + await request.put(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } else { + await request.post(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } + } + + for await (const existingSecret of existingRundeckSecrets) { + if (!(existingSecret in secrets)) { + await request.delete(`${integration.url}/api/44/storage/${integration.path}/${existingSecret}`, { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + }); + } + } + } catch (err: unknown) { + throw new Error( + `Ensure that the provided Rundeck URL is accessible by Infisical and that the linked API token has sufficient permissions.\n\n${ + (err as Error).message + }` + ); + } +}; + /** * Sync/push [secrets] to [app] in integration named [integration] * @@ -3621,6 +3717,13 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.RUNDECK: + await syncSecretsRundeck({ + integration, + secrets, + accessToken + }); + break; default: throw new BadRequestError({ message: "Invalid integration" }); } diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts new file mode 100644 index 0000000000..1ea01e56a8 --- /dev/null +++ b/backend/src/services/integration/integration-schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { INTEGRATION } from "@app/lib/api-docs"; + +import { IntegrationMappingBehavior } from "../integration-auth/integration-list"; + +export const IntegrationMetadataSchema = z.object({ + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), + secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z + .object({ + labelName: z.string(), + labelValue: z.string() + }) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete), + shouldEnableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldEnableDelete), + shouldMaskSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldMaskSecrets), + shouldProtectSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldProtectSecrets) +}); diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 821267dfb3..da9cfc71fa 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -43,6 +43,7 @@ export const integrationServiceFactory = ({ scope, actorId, region, + url, isActive, metadata, secretPath, @@ -87,6 +88,7 @@ export const integrationServiceFactory = ({ region, scope, owner, + url, appId, path, app, diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 1c87724783..abbccbe90b 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -12,6 +12,7 @@ export type TCreateIntegrationDTO = { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -28,6 +29,9 @@ export type TCreateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; @@ -53,6 +57,7 @@ export type TUpdateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; diff --git a/backend/src/services/kms/kms-dal.ts b/backend/src/services/kms/kms-dal.ts new file mode 100644 index 0000000000..bee667e10a --- /dev/null +++ b/backend/src/services/kms/kms-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TKmsDALFactory = ReturnType; + +export const kmsDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsKey); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-root-config-dal.ts b/backend/src/services/kms/kms-root-config-dal.ts new file mode 100644 index 0000000000..f448e2df86 --- /dev/null +++ b/backend/src/services/kms/kms-root-config-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TKmsRootConfigDALFactory = ReturnType; + +export const kmsRootConfigDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsServerRootConfig); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts new file mode 100644 index 0000000000..97d2b29d67 --- /dev/null +++ b/backend/src/services/kms/kms-service.ts @@ -0,0 +1,126 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { randomSecureBytes } from "@app/lib/crypto"; +import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { TKmsDALFactory } from "./kms-dal"; +import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; +import { TDecryptWithKmsDTO, TEncryptWithKmsDTO, TGenerateKMSDTO } from "./kms-types"; + +type TKmsServiceFactoryDep = { + kmsDAL: TKmsDALFactory; + kmsRootConfigDAL: Pick; + keyStore: Pick; +}; + +export type TKmsServiceFactory = ReturnType; + +const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key"; +const KMS_ROOT_CREATION_WAIT_TIME = 10; + +// akhilmhdh: Don't edit this value. This is measured for blob concatination in kms +const KMS_VERSION = "v01"; +const KMS_VERSION_BLOB_LENGTH = 3; +export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsServiceFactoryDep) => { + let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); + + // this is used symmetric encryption + const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const kmsKeyMaterial = randomSecureBytes(32); + const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); + + const { encryptedKey, ...doc } = await kmsDAL.create({ + version: 1, + encryptedKey: encryptedKeyMaterial, + encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + isReserved, + orgId: scopeType === "org" ? scopeId : undefined, + projectId: scopeType === "project" ? scopeId : undefined + }); + return doc; + }; + + const encrypt = async ({ kmsId, plainText }: TEncryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey); + + // Buffer#1 encrypted text + Buffer#2 version number + const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3 + const cipherTextBlob = Buffer.concat([encryptedPlainTextBlob, versionBlob]); + return { cipherTextBlob }; + }; + + const decrypt = async ({ cipherTextBlob: versionedCipherTextBlob, kmsId }: TDecryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + + const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); + const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey); + return decryptedBlob; + }; + + const startService = async () => { + const appCfg = getConfig(); + // This will switch to a seal process and HMS flow in future + const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY; + // if root key its base64 encoded + const isBase64 = !appCfg.ENCRYPTION_KEY; + if (!encryptionKey) throw new Error("Root encryption key not found for KMS service."); + const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); + + const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null); + if (!lock) { + await keyStore.waitTillReady({ + key: KMS_ROOT_CREATION_WAIT_KEY, + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.info("KMS. Waiting for leader to finish creation of KMS Root Key") + }); + } + + // check if KMS root key was already generated and saved in DB + const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + if (kmsRootConfig) { + if (lock) await lock.release(); + logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting."); + const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; + return; + } + + logger.info("KMS: Generating ROOT Key"); + const newRootKey = randomSecureBytes(32); + const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID }); + + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Saved and loaded ROOT Key into memory"); + if (lock) await lock.release(); + ROOT_ENCRYPTION_KEY = newRootKey; + }; + + return { + startService, + generateKmsKey, + encrypt, + decrypt + }; +}; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts new file mode 100644 index 0000000000..96ad25f6e0 --- /dev/null +++ b/backend/src/services/kms/kms-types.ts @@ -0,0 +1,15 @@ +export type TGenerateKMSDTO = { + scopeType: "project" | "org"; + scopeId: string; + isReserved?: boolean; +}; + +export type TEncryptWithKmsDTO = { + kmsId: string; + plainText: Buffer; +}; + +export type TDecryptWithKmsDTO = { + kmsId: string; + cipherTextBlob: Buffer; +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 60ddc52306..ceeb888dfc 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -315,6 +315,7 @@ export const orgServiceFactory = ({ }, tx ); + await licenseService.updateSubscriptionOrgMemberCount(org.id); await orgBotDAL.create( { name: org.name, diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 3cd6c4e6eb..6758f48157 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -309,7 +309,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; const expandSecrets = async ( - secrets: Record + secrets: Record ) => { const expandedSec: Record = {}; const interpolatedSec: Record = {}; @@ -329,8 +329,8 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD // should not do multi line encoding if user has set it to skip // eslint-disable-next-line secrets[key].value = secrets[key].skipMultilineEncoding - ? expandedSec[key] - : formatMultiValueEnv(expandedSec[key]); + ? formatMultiValueEnv(expandedSec[key]) + : expandedSec[key]; // eslint-disable-next-line continue; } @@ -347,7 +347,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD ); // eslint-disable-next-line - secrets[key].value = secrets[key].skipMultilineEncoding ? expandedVal : formatMultiValueEnv(expandedVal); + secrets[key].value = secrets[key].skipMultilineEncoding ? formatMultiValueEnv(expandedVal) : expandedVal; } return secrets; @@ -395,7 +395,8 @@ export const decryptSecretRaw = ( type: secret.type, _id: secret.id, id: secret.id, - user: secret.userId + user: secret.userId, + skipMultilineEncoding: secret.skipMultilineEncoding }; }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index d40a18e5ef..42e13b4456 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,4 +1,6 @@ /* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; @@ -67,7 +69,10 @@ const MAX_SYNC_SECRET_DEPTH = 5; export const uniqueSecretQueueKey = (environment: string, secretPath: string) => `secret-queue-dedupe-${environment}-${secretPath}`; -type TIntegrationSecret = Record; +type TIntegrationSecret = Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined } +>; export const secretQueueFactory = ({ queueService, integrationDAL, @@ -567,11 +572,14 @@ export const secretQueueFactory = ({ isSynced: true }); } catch (err: unknown) { - logger.info("Secret integration sync error:", err); + logger.info("Secret integration sync error: %o", err); + const message = + err instanceof AxiosError ? JSON.stringify((err as AxiosError)?.response?.data) : (err as Error)?.message; + await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), - syncMessage: (err as Error)?.message, + syncMessage: message, isSynced: false }); } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 5688f7f152..d6682a2536 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -952,15 +952,49 @@ export const secretServiceFactory = ({ }); const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); - const decryptedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ - ...el, - secrets: importedSecrets.map((sec) => + const processedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => { + const decryptedImportSecrets = importedSecrets.map((sec) => decryptSecretRaw( { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, botKey ) - ) - })); + ); + + // secret-override to handle duplicate keys from different import levels + // this prioritizes secret values from direct imports + const importedKeys = new Set(); + const importedEntries = decryptedImportSecrets.reduce( + ( + accum: { + secretKey: string; + secretPath: string; + workspace: string; + environment: string; + secretValue: string; + secretComment: string; + version: number; + type: string; + _id: string; + id: string; + user: string | null | undefined; + skipMultilineEncoding: boolean | null | undefined; + }[], + sec + ) => { + if (!importedKeys.has(sec.secretKey)) { + importedKeys.add(sec.secretKey); + return [...accum, sec]; + } + return accum; + }, + [] + ); + + return { + ...el, + secrets: importedEntries + }; + }); if (expandSecretReferences) { const expandSecrets = interpolateSecrets({ @@ -971,10 +1005,24 @@ export const secretServiceFactory = ({ }); const batchSecretsExpand = async ( - secretBatch: { secretKey: string; secretValue: string; secretComment?: string; secretPath: string }[] + secretBatch: { + secretKey: string; + secretValue: string; + secretComment?: string; + secretPath: string; + skipMultilineEncoding: boolean | null | undefined; + }[] ) => { // Group secrets by secretPath - const secretsByPath: Record = {}; + const secretsByPath: Record< + string, + { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding: boolean | null | undefined; + }[] + > = {}; secretBatch.forEach((secret) => { if (!secretsByPath[secret.secretPath]) { @@ -990,11 +1038,15 @@ export const secretServiceFactory = ({ continue; } - const secretRecord: Record = {}; + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding: boolean | null | undefined } + > = {}; secretsByPath[secPath].forEach((decryptedSecret) => { secretRecord[decryptedSecret.secretKey] = { value: decryptedSecret.secretValue, - comment: decryptedSecret.secretComment + comment: decryptedSecret.secretComment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding }; }); @@ -1011,12 +1063,12 @@ export const secretServiceFactory = ({ await batchSecretsExpand(decryptedSecrets); // expand imports by batch - await Promise.all(decryptedImports.map((decryptedImport) => batchSecretsExpand(decryptedImport.secrets))); + await Promise.all(processedImports.map((processedImport) => batchSecretsExpand(processedImport.secrets))); } return { secrets: decryptedSecrets, - imports: decryptedImports + imports: processedImports }; }; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7d6b98b313..1fb89c5537 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -41,21 +41,8 @@ export enum SmtpHost { Office365 = "smtp.office365.com" } -export const getTlsOption = (host?: SmtpHost | string, secure?: boolean) => { - if (!secure) return { secure: false }; - if (!host) return { secure: true }; - - if ((host as SmtpHost) === SmtpHost.Sendgrid) { - return { secure: true, port: 465 }; // more details here https://nodemailer.com/smtp/ - } - if (host.includes("amazonaws.com")) { - return { tls: { ciphers: "TLSv1.2" } }; - } - return { requireTLS: true, tls: { ciphers: "TLSv1.2" } }; -}; - export const smtpServiceFactory = (cfg: TSmtpConfig) => { - const smtp = createTransport({ ...cfg, ...getTlsOption(cfg.host, cfg.secure) }); + const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { diff --git a/docs/images/integrations/rundeck/integrations-rundeck-auth.png b/docs/images/integrations/rundeck/integrations-rundeck-auth.png new file mode 100644 index 0000000000..8ffa693650 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-auth.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-create.png b/docs/images/integrations/rundeck/integrations-rundeck-create.png new file mode 100644 index 0000000000..691c346f91 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-create.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-token.png b/docs/images/integrations/rundeck/integrations-rundeck-token.png new file mode 100644 index 0000000000..70ae704d11 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-token.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck.png b/docs/images/integrations/rundeck/integrations-rundeck.png new file mode 100644 index 0000000000..170e77a3f8 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck.png differ diff --git a/docs/integrations/cicd/rundeck.mdx b/docs/integrations/cicd/rundeck.mdx new file mode 100644 index 0000000000..a0743fd01f --- /dev/null +++ b/docs/integrations/cicd/rundeck.mdx @@ -0,0 +1,39 @@ +--- +title: "Rundeck" +description: "How to sync secrets from Infisical to Rundeck" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + Obtain a User API Token in the Profile settings of Rundeck + + ![integrations rundeck token](../../images/integrations/rundeck/integrations-rundeck-token.png) + + Navigate to your project's integrations tab in Infisical. + + ![integrations](../../images/integrations.png) + + Press on the Rundeck tile and input your Rundeck instance Base URL and User API token to grant Infisical access to manage Rundeck keys + + ![integrations rundeck authorization](../../images/integrations/rundeck/integrations-rundeck-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + + Select which Infisical environment secrets you want to sync to a Rundeck Key Storage Path and press create integration to start syncing secrets to Rundeck. + + ![create integration rundeck](../../images/integrations/rundeck/integrations-rundeck-create.png) + ![integrations rundeck](../../images/integrations/rundeck/integrations-rundeck.png) + + + diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 784f934eee..b29db8420c 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -26,14 +26,14 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | +| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | | [Windmill](/integrations/cloud/windmill) | Cloud | Available | | [BitBucket](/integrations/cicd/bitbucket) | CI/CD | Available | | [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | @@ -41,6 +41,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | | [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | | [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | +| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 3d1b72331a..41a41726c1 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -496,7 +496,6 @@ To enable auto redeployment you simply have to add the following annotation to t ```yaml secrets.infisical.com/auto-reload: "true" ``` - ```yaml apiVersion: apps/v1 @@ -527,7 +526,11 @@ spec: - containerPort: 80 ``` - + + #### How it works + When a secret change occurs, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. + Then, for each deployment that has this annotation present, a rolling update will be triggered. + ## Global configuration To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. diff --git a/docs/mint.json b/docs/mint.json index 5c5097d420..3103bb59a3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,10 +32,7 @@ "thumbsRating": true }, "api": { - "baseUrl": [ - "https://app.infisical.com", - "http://localhost:8080" - ] + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] }, "topbarLinks": [ { @@ -76,9 +73,7 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": [ - "documentation/guides/local-development" - ] + "pages": ["documentation/guides/local-development"] }, { "group": "Guides", @@ -221,9 +216,7 @@ }, { "group": "Reference architectures", - "pages": [ - "self-hosting/reference-architectures/aws-ecs" - ] + "pages": ["self-hosting/reference-architectures/aws-ecs"] }, "self-hosting/ee", "self-hosting/faq" @@ -343,6 +336,7 @@ "pages": [ "integrations/cicd/circleci", "integrations/cicd/travisci", + "integrations/cicd/rundeck", "integrations/cicd/codefresh", "integrations/cloud/checkly" ] @@ -379,21 +373,18 @@ }, { "group": "Build Tool Integrations", - "pages": [ - "integrations/build-tools/gradle" - ] + "pages": ["integrations/build-tools/gradle"] }, { "group": "", - "pages": [ - "sdks/overview" - ] + "pages": ["sdks/overview"] }, { "group": "SDK's", "pages": [ "sdks/languages/node", "sdks/languages/python", + "sdks/languages/go", "sdks/languages/java", "sdks/languages/csharp" ] @@ -405,9 +396,7 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": [ - "api-reference/overview/examples/integration" - ] + "pages": ["api-reference/overview/examples/integration"] } ] }, @@ -563,15 +552,11 @@ }, { "group": "Service Tokens", - "pages": [ - "api-reference/endpoints/service-tokens/get" - ] + "pages": ["api-reference/endpoints/service-tokens/get"] }, { "group": "Audit Logs", - "pages": [ - "api-reference/endpoints/audit-logs/export-audit-log" - ] + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] } ] }, @@ -587,9 +572,7 @@ }, { "group": "", - "pages": [ - "changelog/overview" - ] + "pages": ["changelog/overview"] }, { "group": "Contributing", @@ -613,9 +596,7 @@ }, { "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] + "pages": ["contributing/sdk/developing"] } ] } diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx new file mode 100644 index 0000000000..e60a578bcb --- /dev/null +++ b/docs/sdks/languages/go.mdx @@ -0,0 +1,438 @@ +--- +title: "Infisical Go SDK" +sidebarTitle: "Go" +icon: "golang" +--- + + + +If you're working with Go Lang, the official [Infisical Go SDK](https://github.com/infisical/go-sdk) package is the easiest way to fetch and work with secrets for your application. + +- [Package](https://pkg.go.dev/github.com/infisical/go-sdk) +- [Github Repository](https://github.com/infiscial/go-sdk) + +## Basic Usage + +```go +package main + +import ( + "fmt" + "os" + + infisical "github.com/infisical/go-sdk" +) + +func main() { + + client, err := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://app.infisical.com + }) + + if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) + } + + _, err = client.Auth().UniversalAuthLogin("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET") + + if err != nil { + fmt.Printf("Authentication failed: %v", err) + os.Exit(1) + } + + apiKeySecret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ + SecretKey: "API_KEY", + Environment: "dev", + ProjectID: "YOUR_PROJECT_ID", + SecretPath: "/", + }) + + if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) + } + + fmt.Printf("API Key Secret: %v", apiKeySecret) + +} +``` + +This example demonstrates how to use the Infisical Go SDK in a simple Go application. The application retrieves a secret named `API_KEY` from the `dev` environment of the `YOUR_PROJECT_ID` project. + + + We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + + +# Installation + +```console +$ go get github.com/infisical/go-sdk +``` +# Configuration + +Import the SDK and create a client instance. + +```go +client, err := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://api.infisical.com + }) + +if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) +} +``` + +### ClientSettings methods + + + + + The URL of the Infisical API. Default is `https://api.infisical.com`. + + + + Optionally set the user agent that will be used for HTTP requests. _(Not recommended)_ + + + + + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** + +Call `.Auth().UniversalAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```go +_, err := client.Auth().UniversalAuthLogin("CLIENT_ID", "CLIENT_SECRET") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().GcpIdTokenAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err := client.Auth().GcpIdTokenAuthLogin("YOUR_MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### GCP IAM Auth + +**Using environment variables** + +Call `.Auth().GcpIamAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```go +_, err = client.Auth().GcpIamAuthLogin("MACHINE_IDENTITY_ID", "SERVICE_ACCOUNT_KEY_FILE_PATH") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().AwsIamAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err = client.Auth().AwsIamAuthLogin("MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().AzureAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err = client.Auth().AzureAuthLogin("MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().KubernetesAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```go +// Service account token path will default to /var/run/secrets/kubernetes.io/serviceaccount/token if empty value is passed +_, err = client.Auth().KubernetesAuthLogin("MACHINE_IDENTITY_ID", "SERVICE_ACCOUNT_TOKEN_PATH") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +## Working with Secrets + +### client.Secrets().List(options) + +```go +secrets, err := client.Secrets().List(infisical.ListSecretsOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretPath: "/foo/bar", + AttachToProcessEnv: false, +}) +``` + +Retrieve all secrets within the Infisical project and environment that client is connected to + +#### Parameters + + + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + + The project ID where the secret lives in. + + + + The path from where secrets should be fetched from. + + + + Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. + + + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + + + + + +### client.Secrets().Get(options) + +```go +secret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ + SecretKey: "API_KEY", + ProjectID: "PROJECT_ID", + Environment: "dev", +}) +``` + +Retrieve a secret from Infisical. + +By default, `Secrets().Get()` fetches and returns a shared secret. + +#### Parameters + + + + + The key of the secret to retrieve. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be fetched from. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Create(options) + +```go +secret, err := client.Secrets().Create(infisical.CreateSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + + SecretKey: "NEW_SECRET_KEY", + SecretValue: "NEW_SECRET_VALUE", + SecretComment: "This is a new secret", +}) +``` + +Create a new secret in Infisical. + +#### Parameters + + + + + The key of the secret to create. + + + The value of the secret. + + + A comment for the secret. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be created. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Update(options) + +```go +secret, err := client.Secrets().Update(infisical.UpdateSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretKey: "NEW_SECRET_KEY", + NewSecretValue: "NEW_SECRET_VALUE", + NewSkipMultilineEncoding: false, +}) +``` + +Update an existing secret in Infisical. + +#### Parameters + + + + + The key of the secret to update. + + + The new value of the secret. + + + Whether or not to skip multiline encoding for the new secret value. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be updated. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Delete(options) + +```go +secret, err := client.Secrets().Delete(infisical.DeleteSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretKey: "SECRET_KEY", +}) +``` + +Delete a secret in Infisical. + +#### Parameters + + + + + The key of the secret to update. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be deleted. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + \ No newline at end of file diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 5233ae9105..b225a3ace6 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -48,44 +48,44 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. - - Hostname to connect to for establishing SMTP connections - - -{" "} - - - Credential to connect to host (e.g. team@infisical.com) + + Hostname to connect to for establishing SMTP connections -{" "} - - - Credential to connect to host - - -{" "} - Port to connect to for establishing SMTP connections -{" "} - - - If true, use TLS when connecting to host. If false, TLS will be used if - STARTTLS is supported + + Credential to connect to host (e.g. team@infisical.com) -{" "} + + Credential to connect to host + Email address to be used for sending emails - - Name label to be used in From field (e.g. Team) - + + Name label to be used in From field (e.g. Team) + + + + If this is `true` and `SMTP_PORT` is not 465 then TLS is not used even if the + server supports STARTTLS extension. + + + + If this is `true` and `SMTP_PORT` is not 465 then Infisical tries to use + STARTTLS even if the server does not advertise support for it. If the + connection can not be encrypted then message is not sent. + + + + If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use. + @@ -105,7 +105,6 @@ SMTP_HOST=smtp.sendgrid.net SMTP_USERNAME=apikey SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -128,7 +127,6 @@ SMTP_HOST=smtp.mailgun.org # obtained from credentials page SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page SMTP_PASSWORD=password # obtained from credentials page SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -159,7 +157,6 @@ SMTP_FROM_NAME=Infisical SMTP_USERNAME=xxx # your SMTP username SMTP_PASSWORD=xxx # your SMTP password SMTP_PORT=465 - SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -187,7 +184,6 @@ SMTP_HOST=smtp.socketlabs.com SMTP_USERNAME=username # obtained from your credentials SMTP_PASSWORD=password # obtained from your credentials SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -229,7 +225,6 @@ SMTP_HOST=smtp.resend.com SMTP_USERNAME=resend SMTP_PASSWORD=YOUR_API_KEY SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -253,7 +248,6 @@ SMTP_HOST=smtp.gmail.com SMTP_USERNAME=hey@gmail.com # your email SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@gmail.com SMTP_FROM_NAME=Infisical ``` @@ -277,7 +271,6 @@ SMTP_HOST=smtp.office365.com SMTP_USERNAME=username@yourdomain.com # your username SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=username@yourdomain.com SMTP_FROM_NAME=Infisical ``` @@ -294,7 +287,6 @@ SMTP_HOST=smtp.zoho.com SMTP_USERNAME=username # your email SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail SMTP_FROM_NAME=Infisical ``` @@ -318,6 +310,12 @@ SMTP_FROM_NAME=Infisical By default, users can only login via email/password based login method. To login into Infisical with OAuth providers such as Google, configure the associated variables. + + +When set, all visits to the Infisical login page will automatically redirect users of your Infisical instance to the SAML identity provider associated with the specified organization slug. + + + Follow detailed guide to configure [Google SSO](/documentation/platform/sso/google) @@ -369,11 +367,6 @@ To login into Infisical with OAuth providers such as Google, configure the assoc information. - - Configure SAML organization slug to automatically redirect all users of your - Infisical instance to the identity provider. - - ## Native secret integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9090fc603b..5251dc4064 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,6 +2,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id ARG NEXT_INFISICAL_PLATFORM_VERSION=next-infisical-platform-version +ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:16-alpine AS deps # Install dependencies only when needed. Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. @@ -31,6 +32,8 @@ ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -57,7 +60,9 @@ ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG ARG NEXT_INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION=$NEXT_INFISICAL_PLATFORM_VERSION - +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ + BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY COPY --chown=nextjs:nodejs --chmod=555 scripts ./scripts COPY --from=builder /app/public ./public RUN chown nextjs:nodejs ./public/data diff --git a/frontend/next.config.js b/frontend/next.config.js index 9d894694fe..5e48e70da7 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,13 +1,12 @@ - const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com 'unsafe-inline' 'unsafe-eval'; - style-src 'self' https://rsms.me 'unsafe-inline'; + script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; + style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; - frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/; - connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:*; + frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; + connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; media-src https://js.intercomcdn.com; font-src 'self' https://fonts.intercomcdn.com/ https://maxcdn.bootstrapcdn.com https://rsms.me https://fonts.gstatic.com; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c33c9dc360..489df0ea18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -19,6 +18,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", @@ -3200,6 +3200,24 @@ "react": ">=16.3" } }, + "node_modules/@hcaptcha/loader": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-1.2.4.tgz", + "integrity": "sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw==" + }, + "node_modules/@hcaptcha/react-hcaptcha": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.10.1.tgz", + "integrity": "sha512-P0en4gEZAecah7Pt3WIaJO2gFlaLZKkI0+Tfdg8fNqsDxqT9VytZWSkH4WAkiPRULK1QcGgUZK+J56MXYmPifw==", + "dependencies": { + "@babel/runtime": "^7.17.9", + "@hcaptcha/loader": "^1.2.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/@headlessui/react": { "version": "1.7.18", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index e01ef945e6..a4acb57382 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 451890ef98..cf90ef659f 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -32,7 +32,8 @@ const integrationSlugNameMapping: Mapping = { northflank: "Northflank", windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", - "hasura-cloud": "Hasura Cloud" + "hasura-cloud": "Hasura Cloud", + rundeck: "Rundeck" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Rundeck.svg b/frontend/public/images/integrations/Rundeck.svg new file mode 100644 index 0000000000..4ded97a8a3 --- /dev/null +++ b/frontend/public/images/integrations/Rundeck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh index 859814edad..644877d8f8 100755 --- a/frontend/scripts/initialize-standalone-build.sh +++ b/frontend/scripts/initialize-standalone-build.sh @@ -4,7 +4,7 @@ scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" -scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" +scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY" "$NEXT_PUBLIC_CAPTCHA_SITE_KEY" if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" diff --git a/frontend/scripts/start.sh b/frontend/scripts/start.sh index 1488ad328c..7dda6c95b1 100644 --- a/frontend/scripts/start.sh +++ b/frontend/scripts/start.sh @@ -6,6 +6,8 @@ scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTER scripts/replace-variable.sh "$BAKED_NEXT_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" +scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY" "$NEXT_PUBLIC_CAPTCHA_SITE_KEY" + if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-telemetry.sh true diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index e95f5bf885..8f7c4bb9f5 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -30,11 +30,13 @@ export interface IsCliLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); return new Promise((resolve, reject) => { @@ -70,7 +72,8 @@ const attemptLogin = async ({ } = await login2({ email, clientProof, - providerAuthToken + providerAuthToken, + captchaToken }); if (mfaEnabled) { // case: MFA is enabled diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index 195cf9b9a2..b909b1ba7c 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -22,11 +22,13 @@ interface IsLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); // eslint-disable-next-line new-cap @@ -58,6 +60,7 @@ const attemptLogin = async ({ iv, tag } = await login2({ + captchaToken, email, clientProof, providerAuthToken diff --git a/frontend/src/components/utilities/config/index.ts b/frontend/src/components/utilities/config/index.ts index 10d4856c06..9b3bf37f1a 100644 --- a/frontend/src/components/utilities/config/index.ts +++ b/frontend/src/components/utilities/config/index.ts @@ -2,5 +2,6 @@ const ENV = process.env.NEXT_PUBLIC_ENV! || "development"; // investigate const POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY!; const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com"; const INTERCOMid = process.env.NEXT_PUBLIC_INTERCOMid!; +const CAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY!; -export { ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; +export { CAPTCHA_SITE_KEY, ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 12a9094e03..29dba23c72 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -62,6 +62,7 @@ export const Select = forwardRef( ( outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, isSelected && "bg-primary", isDisabled && - "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", + "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", className )} ref={forwardedRef} diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 41c324bffe..ce1b18bc83 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -30,6 +30,7 @@ export type Login1DTO = { }; export type Login2DTO = { + captchaToken?: string; email: string; clientProof: string; providerAuthToken?: string; diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 4a4c5e2812..b73528384c 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -7,6 +7,7 @@ export type IntegrationAuth = { updatedAt: string; algorithm: string; keyEncoding: string; + url?: string; teamId?: string; }; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 7325dc4a3e..81d0f00cae 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -41,6 +41,7 @@ export const useCreateIntegration = () => { owner, path, region, + url, scope, secretPath, metadata @@ -56,6 +57,7 @@ export const useCreateIntegration = () => { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -71,6 +73,9 @@ export const useCreateIntegration = () => { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; }) => { const { @@ -85,6 +90,7 @@ export const useCreateIntegration = () => { targetEnvironmentId, targetService, targetServiceId, + url, owner, path, scope, diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 80d34a1508..911526404c 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -4,4 +4,5 @@ export type ServerStatus = { emailConfigured: boolean; secretScanningConfigured: boolean; redisConfigured: boolean; + samlDefaultOrgSlug: boolean }; diff --git a/frontend/src/pages/integrations/cloudflare-pages/create.tsx b/frontend/src/pages/integrations/cloudflare-pages/create.tsx index 570b2b83a0..d7cb6bdabb 100644 --- a/frontend/src/pages/integrations/cloudflare-pages/create.tsx +++ b/frontend/src/pages/integrations/cloudflare-pages/create.tsx @@ -7,7 +7,15 @@ import { createNotification } from "@app/components/notifications"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api"; -import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; +import { + Button, + Card, + CardTitle, + FormControl, + Select, + SelectItem, + Switch +} from "../../../components/v2"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById @@ -34,6 +42,7 @@ export default function CloudflarePagesIntegrationPage() { const [targetApp, setTargetApp] = useState(""); const [targetAppId, setTargetAppId] = useState(""); const [targetEnvironment, setTargetEnvironment] = useState(""); + const [shouldAutoRedeploy, setShouldAutoRedeploy] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -69,7 +78,10 @@ export default function CloudflarePagesIntegrationPage() { appId: targetAppId, sourceEnvironment: selectedSourceEnvironment, targetEnvironment, - secretPath + secretPath, + metadata: { + shouldAutoRedeploy + } }); setIsLoading(false); @@ -169,6 +181,15 @@ export default function CloudflarePagesIntegrationPage() { ))} +
+ setShouldAutoRedeploy(isChecked)} + isChecked={shouldAutoRedeploy} + > + Auto-redeploy service upon secret change + +
+ + + + ); +} + +RundeckAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/rundeck/create.tsx b/frontend/src/pages/integrations/rundeck/create.tsx new file mode 100644 index 0000000000..543d9f4b0d --- /dev/null +++ b/frontend/src/pages/integrations/rundeck/create.tsx @@ -0,0 +1,217 @@ +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import queryString from "query-string"; +import { z } from "zod"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthById } from "@app/hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "@app/hooks/api/workspace"; + +const schema = z.object({ + keyStoragePath: z.string().trim().min(1, { message: "Rundeck Key Storage path is required" }), + secretPath: z.string().trim().min(1, { message: "Secret path is required" }), + sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }) +}); + +type TFormSchema = z.infer; + +export default function RundeckCreateIntegrationPage() { + const { + control, + handleSubmit, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secretPath: "/" + } + }); + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById( + (integrationAuthId as string) ?? "" + ); + + const selectedSourceEnvironment = watch("sourceEnvironment"); + + const onFormSubmit = async ({ secretPath, sourceEnvironment, keyStoragePath }: TFormSchema) => { + try { + if (!integrationAuth?.id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + path: keyStoragePath, + sourceEnvironment, + url: integrationAuth.url, + secretPath + }); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && workspace ? ( +
+ + Set Up Rundeck Integration + + + + +
+
+ Rundeck logo +
+ Rundeck Integration + + +
+ + Docs + +
+
+ +
+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + +
+
+ ) : ( +
+ + Set Up Rundeck Integration + + + {isIntegrationAuthLoading ? ( + infisical loading indicator + ) : ( +
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + {" "} + if the issue persists. +

+
+ )} +
+ ); +} + +RundeckCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 10ef21e278..1aee4c6568 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -128,6 +128,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "hasura-cloud": link = `${window.location.origin}/integrations/hasura-cloud/authorize`; break; + case "rundeck": + link = `${window.location.origin}/integrations/rundeck/authorize`; + break; default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 267ff85800..d560102783 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -141,7 +141,8 @@ export const IntegrationsSection = ({ label={ (integration.integration === "qovery" && integration?.scope) || (integration.integration === "aws-secret-manager" && "Secret") || - (integration.integration === "aws-parameter-store" && "Path") || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + "Path") || (integration?.integration === "terraform-cloud" && "Project") || (integration?.scope === "github-org" && "Organization") || (["github-repo", "github-env"].includes(integration?.scope as string) && @@ -153,7 +154,7 @@ export const IntegrationsSection = ({ {(integration.integration === "hashicorp-vault" && `${integration.app} - path: ${integration.path}`) || (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && + (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index a4e5e89f5f..6e2c788ae0 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -1,17 +1,20 @@ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; +import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; import { Button, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; +import { useFetchServerStatus } from "@app/hooks/api"; import { navigateUserToSelectOrg } from "../../Login.utils"; @@ -31,21 +34,18 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const [loginError, setLoginError] = useState(false); const { config } = useServerConfig(); const queryParams = new URLSearchParams(window.location.search); + const [captchaToken, setCaptchaToken] = useState(""); + const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false); + const captchaRef = useRef(null); + const { data: serverDetails } = useFetchServerStatus(); useEffect(() => { - if ( - process.env.NEXT_PUBLIC_SAML_ORG_SLUG && - process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default" - ) { - const callbackPort = queryParams.get("callback_port"); - window.open( - `/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${ - callbackPort ? `?callback_port=${callbackPort}` : "" - }` - ); - window.close(); - } - }, []); + if (serverDetails?.samlDefaultOrgSlug){ + const callbackPort = queryParams.get("callback_port"); + const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${serverDetails?.samlDefaultOrgSlug}${callbackPort ? `?callback_port=${callbackPort}` : ""}` + router.push(redirectUrl); + } + }, [serverDetails?.samlDefaultOrgSlug]); const handleLogin = async (e: FormEvent) => { e.preventDefault(); @@ -61,7 +61,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: // attemptCliLogin const isCliLoginSuccessful = await attemptCliLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { @@ -83,7 +84,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: } else { const isLoginSuccessful = await attemptLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isLoginSuccessful && isLoginSuccessful.success) { @@ -117,6 +119,12 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } + if (err.response.data.error === "Captcha Required") { + setShouldShowCaptcha(true); + setIsLoading(false); + return; + } + setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", @@ -124,6 +132,11 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } + if (captchaRef.current) { + captchaRef.current.resetCaptcha(); + } + + setCaptchaToken(""); setIsLoading(false); }; @@ -245,8 +258,19 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: className="select:-webkit-autofill:focus h-10" /> + {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}