diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index b5669a671..02b99537f 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -1,6 +1,7 @@ import { Types } from "mongoose"; import { Request, Response } from "express"; import { MembershipOrg, Organization, User } from "../../models"; +import { SSOConfig } from "../../ee/models"; import { deleteMembershipOrg as deleteMemberFromOrg } from "../../helpers/membershipOrg"; import { createToken } from "../../helpers/auth"; import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; @@ -110,6 +111,18 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { } const plan = await EELicenseService.getPlan(organizationId); + + const ssoConfig = await SSOConfig.findOne({ + organization: new Types.ObjectId(organizationId) + }); + + if (ssoConfig && ssoConfig.isActive) { + // case: SAML SSO is enabled for the organization + return res.status(400).send({ + message: + "Failed to invite member due to SAML SSO configured for organization" + }); + } if (plan.memberLimit !== null) { // case: limit imposed on number of members allowed diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 0c78d7667..237d32671 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -4,6 +4,7 @@ import crypto from "crypto"; import bcrypt from "bcrypt"; import { APIKeyData, + AuthProvider, MembershipOrg, TokenVersion, User @@ -121,6 +122,10 @@ export const updateAuthProvider = async (req: Request, res: Response) => { const { authProvider } = req.body; + + if (req.user?.authProvider === AuthProvider.OKTA_SAML) return res.status(400).send({ + message: "Failed to update user authentication method because SAML SSO is enforced" + }); const user = await User.findByIdAndUpdate( req.user._id.toString(), diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index 601d81de4..4837dfd15 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -10,6 +10,7 @@ import { getSSOConfigHelper } from "../../helpers/organizations"; import { client } from "../../../config"; import { ResourceNotFoundError } from "../../../utils/errors"; import { getSiteURL } from "../../../config"; +import { EELicenseService } from "../../services"; /** * Redirect user to appropriate SSO endpoint after successful authentication @@ -58,6 +59,12 @@ export const updateSSOConfig = async (req: Request, res: Response) => { cert, audience } = req.body; + + const plan = await EELicenseService.getPlan(organizationId); + + if (!plan.samlSSO) return res.status(400).send({ + message: "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); interface PatchUpdate { authProvider?: string; @@ -203,6 +210,12 @@ export const createSSOConfig = async (req: Request, res: Response) => { cert, audience } = req.body; + + const plan = await EELicenseService.getPlan(organizationId); + + if (!plan.samlSSO) return res.status(400).send({ + message: "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to add SSO configuration." + }); const key = await BotOrgService.getSymmetricKey( new Types.ObjectId(organizationId) diff --git a/backend/src/helpers/botOrg.ts b/backend/src/helpers/botOrg.ts index 139e8859e..003cabbdc 100644 --- a/backend/src/helpers/botOrg.ts +++ b/backend/src/helpers/botOrg.ts @@ -3,12 +3,100 @@ import { client, getEncryptionKey, getRootEncryptionKey } from "../config"; import { BotOrg } from "../models"; import { decryptSymmetric128BitHexKeyUTF8 } from "../utils/crypto"; import { + ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8 } from "../variables"; import { InternalServerError } from "../utils/errors"; +import { encryptSymmetric128BitHexKeyUTF8, generateKeyPair } from "../utils/crypto"; -// TODO: DOCstrings +/** + * Create a bot with name [name] for organization with id [organizationId] + * @param {Object} obj + * @param {String} obj.name - name of bot + * @param {String} obj.organizationId - id of organization that bot belongs to + */ +export const createBotOrg = async ({ + name, + organizationId, +}: { + name: string; + organizationId: Types.ObjectId; +}) => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + const { publicKey, privateKey } = generateKeyPair(); + const key = client.createSymmetricKey(); + + if (rootEncryptionKey) { + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag + } = client.encryptSymmetric(privateKey, rootEncryptionKey); + + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag + } = client.encryptSymmetric(key, rootEncryptionKey); + + return await new BotOrg({ + name, + organization: organizationId, + publicKey, + encryptedSymmetricKey, + symmetricKeyIV, + symmetricKeyTag, + symmetricKeyAlgorithm: ALGORITHM_AES_256_GCM, + symmetricKeyKeyEncoding: ENCODING_SCHEME_BASE64, + encryptedPrivateKey, + privateKeyIV, + privateKeyTag, + privateKeyAlgorithm: ALGORITHM_AES_256_GCM, + privateKeyKeyEncoding: ENCODING_SCHEME_BASE64 + }).save(); + } else if (encryptionKey) { + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag + } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: privateKey, + key: encryptionKey + }); + + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag + } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: key, + key: encryptionKey + }); + + return await new BotOrg({ + name, + organization: organizationId, + publicKey, + encryptedSymmetricKey, + symmetricKeyIV, + symmetricKeyTag, + symmetricKeyAlgorithm: ALGORITHM_AES_256_GCM, + symmetricKeyKeyEncoding: ENCODING_SCHEME_UTF8, + encryptedPrivateKey, + privateKeyIV, + privateKeyTag, + privateKeyAlgorithm: ALGORITHM_AES_256_GCM, + privateKeyKeyEncoding: ENCODING_SCHEME_UTF8 + }).save(); + } + + throw InternalServerError({ + message: "Failed to create new organization bot due to missing encryption key", + }); +}; export const getSymmetricKeyHelper = async (organizationId: Types.ObjectId) => { const rootEncryptionKey = await getRootEncryptionKey(); diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 3748f18fe..3123e1c16 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -14,6 +14,9 @@ import { licenseKeyRequest, licenseServerKeyRequest, } from "../config/request"; +import { + createBotOrg +} from "./botOrg"; /** * Create an organization with name [name] @@ -29,6 +32,7 @@ export const createOrganization = async ({ name: string; email: string; }) => { + const licenseServerKey = await getLicenseServerKey(); let organization; @@ -52,6 +56,12 @@ export const createOrganization = async ({ }).save(); } + // initialize bot for organization + await createBotOrg({ + name, + organizationId: organization._id + }); + return organization; }; diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 8d346dfa2..69810d025 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,6 +1,3 @@ -import { Octokit } from "@octokit/rest"; -import { IIntegrationAuth } from "../models"; -import { standardRequest } from "../config/request"; import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, @@ -13,6 +10,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUD_66, + INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, INTEGRATION_CODEFRESH_API_URL, INTEGRATION_DIGITAL_OCEAN_API_URL, @@ -39,6 +38,9 @@ import { INTEGRATION_VERCEL, INTEGRATION_VERCEL_API_URL } from "../variables"; +import { IIntegrationAuth } from "../models"; +import { Octokit } from "@octokit/rest"; +import { standardRequest } from "../config/request"; interface App { name: string; @@ -165,7 +167,14 @@ const getApps = async ({ }); break; case INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM: - apps = await getAppsDigitalOceanAppPlatform({ accessToken }); + apps = await getAppsDigitalOceanAppPlatform({ + accessToken + }); + break; + case INTEGRATION_CLOUD_66: + apps = await getAppsCloud66({ + accessToken, + }); break; } @@ -824,7 +833,6 @@ const getAppsBitBucket = async ({ * @returns {Object[]} apps - names of Supabase apps * @returns {String} apps.name - name of Supabase app */ - const getAppsCodefresh = async ({ accessToken, }: { @@ -849,9 +857,13 @@ const getAppsCodefresh = async ({ }; /** - * Return list of projects for Digital Ocean App Platform integration + * Return list of applications for DigitalOcean App Platform integration + * @param {Object} obj + * @param {String} obj.accessToken - personal access token for DigitalOcean + * @returns {Object[]} apps - names of DigitalOcean apps + * @returns {String} apps.name - name of DigitalOcean app + * @returns {String} apps.appId - id of DigitalOcean app */ - const getAppsDigitalOceanAppPlatform = async ({ accessToken }: { accessToken: string }) => { interface DigitalOceanApp { id: string; @@ -879,12 +891,70 @@ const getAppsDigitalOceanAppPlatform = async ({ accessToken }: { accessToken: st } }) ).data; - + return (res.apps ?? []).map((a: DigitalOceanApp) => ({ name: a.spec.name, appId: a.id })); +} + +/** + * Return list of applications for Cloud66 integration + * @param {Object} obj + * @param {String} obj.accessToken - personal access token for Cloud66 API + * @returns {Object[]} apps - Cloud66 apps + * @returns {String} apps.name - name of Cloud66 app + * @returns {String} apps.appId - uid of Cloud66 app + */ +const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { + interface Cloud66Apps { + uid: string; + name: string; + account_id: number; + git: string; + git_branch: string; + environment: string; + cloud: string; + fqdn: string; + language: string; + framework: string; + status: number; + health: number; + last_activity: string; + last_activity_iso: string; + maintenance_mode: boolean; + has_loadbalancer: boolean; + created_at: string; + updated_at: string; + deploy_directory: string; + cloud_status: string; + backend: string; + version: string; + revision: string; + is_busy: boolean; + account_name: string; + is_cluster: boolean; + is_inside_cluster: boolean; + cluster_name: any; + application_address: string; + configstore_namespace: string; + } + + const stacks = ( + await standardRequest.get(`${INTEGRATION_CLOUD_66_API_URL}/3/stacks`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.response as Cloud66Apps[] + + const apps = stacks.map((app) => ({ + name: app.name, + appId: app.uid + })); + + return apps; }; - export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 5da5531ff..9afb0e4c5 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,14 +1,10 @@ -import _ from "lodash"; -import AWS from "aws-sdk"; import { CreateSecretCommand, GetSecretValueCommand, ResourceNotFoundException, SecretsManagerClient, - UpdateSecretCommand, + UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; -import { Octokit } from "@octokit/rest"; -import sodium from "libsodium-wrappers"; import { IIntegration, IIntegrationAuth } from "../models"; import { INTEGRATION_AWS_PARAMETER_STORE, @@ -22,6 +18,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUD_66, + INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, INTEGRATION_CODEFRESH_API_URL, INTEGRATION_DIGITAL_OCEAN_API_URL, @@ -49,6 +47,10 @@ import { INTEGRATION_VERCEL, INTEGRATION_VERCEL_API_URL } from "../variables"; +import AWS from "aws-sdk"; +import { Octokit } from "@octokit/rest"; +import _ from "lodash"; +import sodium from "libsodium-wrappers"; import { standardRequest } from "../config/request"; /** @@ -229,6 +231,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_CLOUD_66: + await syncSecretsCloud66({ + integration, + secrets, + accessToken + }); + break; } }; @@ -2077,10 +2086,11 @@ const syncSecretsBitBucket = async ({ } } -/* - * Sync/push [secrets] to Codefresh with name [integration.app] +/** + * Sync/push [secrets] to Codefresh project with name [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 Codefresh integration */ @@ -2110,6 +2120,14 @@ const syncSecretsCodefresh = async ({ ); }; +/** + * Sync/push [secrets] to DigitalOcean App Platform application with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 - personal access token for DigitalOcean + */ const syncSecretsDigitalOceanAppPlatform = async ({ integration, secrets, @@ -2134,6 +2152,109 @@ const syncSecretsDigitalOceanAppPlatform = async ({ } } ); +} + +/** + * Sync/push [secrets] to Cloud66 application with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 Cloud66 integration + */ +const syncSecretsCloud66 = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + + interface Cloud66Secret { + id: number; + key: string; + value: string; + readonly: boolean; + created_at: string; + updated_at: string; + is_password: boolean; + is_generated: boolean; + history: any[]; + } + + // get all current secrets + const res = ( + await standardRequest.get( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ) + .data + .response + .filter((secret: Cloud66Secret) => !secret.readonly || !secret.is_generated) + .reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), + {} + ); + + for await (const key of Object.keys(secrets)) { + if (key in res) { + // update existing secret + await standardRequest.put( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments/${key}`, + { + key, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } else { + // create new secret + await standardRequest.post( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + key, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } + + for await (const key of Object.keys(res)) { + if (!(key in secrets)) { + // delete secret + await standardRequest.delete( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments/${key}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } }; export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index bcaf4cd27..e79e85ce9 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,4 +1,3 @@ -import { Schema, Types, model } from "mongoose"; import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, @@ -7,6 +6,7 @@ import { INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, @@ -22,6 +22,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Schema, Types, model } from "mongoose"; export interface IIntegration { _id: Types.ObjectId; @@ -61,6 +62,7 @@ export interface IIntegration { | "bitbucket" | "codefresh" | "digital-ocean-app-platform" + | "cloud-66" integrationAuth: Types.ObjectId; } @@ -152,7 +154,8 @@ const integrationSchema = new Schema( INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 31b9d39c8..8d1ea1e56 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,4 +1,3 @@ -import { Document, Schema, Types, model } from "mongoose"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, @@ -9,6 +8,7 @@ import { INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, @@ -24,6 +24,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Document, Schema, Types, model } from "mongoose"; export interface IIntegrationAuth extends Document { _id: Types.ObjectId; @@ -48,7 +49,8 @@ export interface IIntegrationAuth extends Document { | "cloudflare-pages" | "codefresh" | "digital-ocean-app-platform" - | "bitbucket"; + | "bitbucket" + | "cloud-66"; teamId: string; accountId: string; url: string; @@ -96,7 +98,8 @@ const integrationAuthSchema = new Schema( INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, ], required: true, }, diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index 919b6c0ce..1405af000 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -177,7 +177,6 @@ export const backfillBotOrgs = async () => { return new BotOrg({ name: "Infisical Bot", organization: organizationToAddBot, - isActive: false, publicKey, encryptedSymmetricKey, symmetricKeyIV, @@ -212,7 +211,6 @@ export const backfillBotOrgs = async () => { return new BotOrg({ name: "Infisical Bot", organization: organizationToAddBot, - isActive: false, publicKey, encryptedSymmetricKey, symmetricKeyIV, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 1f5dfca1f..95c5b7116 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -30,6 +30,7 @@ export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; export const INTEGRATION_BITBUCKET = "bitbucket"; export const INTEGRATION_CODEFRESH = "codefresh"; export const INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform"; +export const INTEGRATION_CLOUD_66 = "cloud-66"; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -48,7 +49,8 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66 ]); // integration types @@ -82,6 +84,7 @@ export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com" export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; export const INTEGRATION_DIGITAL_OCEAN_API_URL = "https://api.digitalocean.com"; +export const INTEGRATION_CLOUD_66_API_URL = "https://app.cloud66.com/api"; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -284,6 +287,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Cloud 66", + slug: "cloud-66", + image: "Cloud 66.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, ] return INTEGRATION_OPTIONS; diff --git a/docs/documentation/platform/saml.mdx b/docs/documentation/platform/saml.mdx new file mode 100644 index 000000000..6f58f7900 --- /dev/null +++ b/docs/documentation/platform/saml.mdx @@ -0,0 +1,100 @@ +--- +title: "SSO" +description: "Log in to Infisical via SSO protocols" +--- + + + Infisical currently only supports SAML SSO authentication with [Okta as the + identity provider (IDP)](https://www.okta.com/). We're expanding support for + other IDPs in the coming months, so stay tuned with this issue + [here](https://github.com/Infisical/infisical/issues/442). + + +You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). + +To note, configuring SSO retains the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps. In all login with SSO implementations, +your IDP cannot and will not have access to the decryption key needed to decrypt your secrets. + +## Configuration + +Head over to your organization Settings > Authentication > SAML SSO Configuration. + +Next, press "Set up SAML SSO" in the SAML SSO and follow the instructions +below to configure SSO for your identity provider: + + + Note that only members with the `owner` or `admin` roles in an organization + can configure SSO for it. + + + + + 1. In the Okta Admin Portal, select Applications > Applications from the + navigation. On the Applications screen, select the Create App Integration + button. + + ![SAML Okta create app integration](../../images/saml-okta-1.png) + + 2. In the Create a New Application Integration dialog, select the SAML 2.0 radio button: + + ![SAML Okta create SAML 2.0 integration](../../images/saml-okta-2.png) + + 3. On the General Settings screen, give the application a unique, Infisical-specific name and select Next. + + 4. On the Configure SAML screen, configure the following fields: + + - Single sign on URL: `https://app.infisical.com/api/v1/sso/saml2/:identifier`; we'll update the `:identifier` part later in step 6. + - Audience URI (SP Entity ID): `https://app.infisical.com` + + ![SAML Okta configure IDP fields](../../images/saml-okta-3.png) + + + If you're self-hosting Infisical, then you will want to replace `https://app.infisical.com` with your own domain. + + + 4. Also on the Configure SAML screen, configure the Attribute Statements to map: + + - `id -> user.id`, + - `email -> user.email`, + - `firstName -> user.firstName` + - `lastName -> user.lastName` + + ![SAML Okta attribute statements](../../images/saml-okta-4.png) + + Once configured, select the Next button to proceed to the Feedback screen and select Finish. + + 5. Get IDP values + + Once your application is created, select the Sign On tab for the app and select the View Setup Instructions button located on the right side of the screen: + + Copy the Identity Provider Single Sign-On URL, the Identity Provider Issuer, and the X.509 Certificate to be pasted into your Infisical SAML SSO configuration details with the following map: + + - `Audience -> Okta Audience URI (SP Entity ID)` + - `Entrypoint -> Okta Identity Provider Single Sign-On URL` + - `Issuer -> Identity Provider Issuer` + - `Certificate -> X.509 Certificate`. + + ![SAML Okta IDP values](../../images/saml-okta-5.png) + + ![SAML Okta paste values into Infisical](../../images/saml-okta-6.png) + + 6. Create the SSO configuration and copy your SSO identifier in Infisical; update `:identifier` from step 4 earlier to be this value. + + ![SAML Okta assignments](../../images/saml-okta-7.png) + + 7. Assignments + + Finally, Navigate to the Assignments tab and select the Assign button: + + You can assign access to the application on a user-by-user basis using the Assign to People option, or in-bulk using the Assign to Groups option. + + ![SAML Okta assignment](../../images/saml-okta-8.png) + + At this point, you have configured everything you need within the context of the Okta Admin Portal. + + 8. Return to Infisical and enable SAML SSO. + + Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via Okta. + + + diff --git a/docs/images/integrations-cloud-66-access-token.png b/docs/images/integrations-cloud-66-access-token.png new file mode 100644 index 000000000..a3532a6b4 Binary files /dev/null and b/docs/images/integrations-cloud-66-access-token.png differ diff --git a/docs/images/integrations-cloud-66-copy-pat.png b/docs/images/integrations-cloud-66-copy-pat.png new file mode 100644 index 000000000..41d9e14af Binary files /dev/null and b/docs/images/integrations-cloud-66-copy-pat.png differ diff --git a/docs/images/integrations-cloud-66-create.png b/docs/images/integrations-cloud-66-create.png new file mode 100644 index 000000000..24b14c398 Binary files /dev/null and b/docs/images/integrations-cloud-66-create.png differ diff --git a/docs/images/integrations-cloud-66-dashboard.png b/docs/images/integrations-cloud-66-dashboard.png new file mode 100644 index 000000000..4598fc915 Binary files /dev/null and b/docs/images/integrations-cloud-66-dashboard.png differ diff --git a/docs/images/integrations-cloud-66-done.png b/docs/images/integrations-cloud-66-done.png new file mode 100644 index 000000000..0541374b6 Binary files /dev/null and b/docs/images/integrations-cloud-66-done.png differ diff --git a/docs/images/integrations-cloud-66-infisical-dashboard.png b/docs/images/integrations-cloud-66-infisical-dashboard.png new file mode 100644 index 000000000..e54eea726 Binary files /dev/null and b/docs/images/integrations-cloud-66-infisical-dashboard.png differ diff --git a/docs/images/integrations-cloud-66-paste-pat.png b/docs/images/integrations-cloud-66-paste-pat.png new file mode 100644 index 000000000..276da0e0d Binary files /dev/null and b/docs/images/integrations-cloud-66-paste-pat.png differ diff --git a/docs/images/integrations-cloud-66-pat-setup.png b/docs/images/integrations-cloud-66-pat-setup.png new file mode 100644 index 000000000..0d54641fc Binary files /dev/null and b/docs/images/integrations-cloud-66-pat-setup.png differ diff --git a/docs/images/integrations-cloud-66-pat.png b/docs/images/integrations-cloud-66-pat.png new file mode 100644 index 000000000..fd3434374 Binary files /dev/null and b/docs/images/integrations-cloud-66-pat.png differ diff --git a/docs/images/saml-okta-1.png b/docs/images/saml-okta-1.png new file mode 100644 index 000000000..8ffac381b Binary files /dev/null and b/docs/images/saml-okta-1.png differ diff --git a/docs/images/saml-okta-2.png b/docs/images/saml-okta-2.png new file mode 100644 index 000000000..717737af9 Binary files /dev/null and b/docs/images/saml-okta-2.png differ diff --git a/docs/images/saml-okta-3.png b/docs/images/saml-okta-3.png new file mode 100644 index 000000000..eccc8d277 Binary files /dev/null and b/docs/images/saml-okta-3.png differ diff --git a/docs/images/saml-okta-4.png b/docs/images/saml-okta-4.png new file mode 100644 index 000000000..e3c413a6f Binary files /dev/null and b/docs/images/saml-okta-4.png differ diff --git a/docs/images/saml-okta-5.png b/docs/images/saml-okta-5.png new file mode 100644 index 000000000..4acc846f9 Binary files /dev/null and b/docs/images/saml-okta-5.png differ diff --git a/docs/images/saml-okta-6.png b/docs/images/saml-okta-6.png new file mode 100644 index 000000000..82300afdb Binary files /dev/null and b/docs/images/saml-okta-6.png differ diff --git a/docs/images/saml-okta-7.png b/docs/images/saml-okta-7.png new file mode 100644 index 000000000..2bd4a84e9 Binary files /dev/null and b/docs/images/saml-okta-7.png differ diff --git a/docs/images/saml-okta-8.png b/docs/images/saml-okta-8.png new file mode 100644 index 000000000..bf9f98301 Binary files /dev/null and b/docs/images/saml-okta-8.png differ diff --git a/docs/integrations/cloud/cloud-66.mdx b/docs/integrations/cloud/cloud-66.mdx new file mode 100644 index 000000000..86c28440e --- /dev/null +++ b/docs/integrations/cloud/cloud-66.mdx @@ -0,0 +1,55 @@ +--- +title: "Cloud 66" +description: "How to sync secrets from Infisical to Cloud 66" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Cloud 66 Access Token + +In Cloud 66 Dashboard, click on the top right icon > Account Settings > Access Token +![integrations cloud 66 dashboard](../../images/integrations-cloud-66-dashboard.png) +![integrations cloud 66 access token](../../images/integrations-cloud-66-access-token.png) + +Create new Personal Access Token. +![integrations cloud 66 personal access token](../../images/integrations-cloud-66-pat.png) + +Name it **infisical** and check **Public** and **Admin**. Then click "Create Token" +![integrations cloud 66 personal access token setup](../../images/integrations-cloud-66-pat-setup.png) + +Copy and save your token. +![integrations cloud 66 copy API token](../../images/integrations-cloud-66-copy-pat.png) + +### Go to Infisical Integration Page + +Click on the Cloud 66 tile and enter your API token to grant Infisical access to your Cloud 66 account. +![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-infisical-dashboard.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. + + +Enter your Cloud 66 Personal Access Token here. Then click "Connect to Cloud 66". +![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-paste-pat.png) + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Cloud 66 stacks and press create integration to start syncing secrets to Cloud 66. +![integrations laravel forge](../../images/integrations-cloud-66-create.png) + + + Any existing environment variables in Cloud 66 will be deleted when you start syncing. Make sure to add all the secrets into the Infisical dashboard first before doing any integrations. + + +Done! +![integrations laravel forge](../../images/integrations-cloud-66-done.png) diff --git a/docs/mint.json b/docs/mint.json index d7e962e9c..48755ea92 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,8 +118,9 @@ "documentation/platform/pit-recovery", "documentation/platform/secret-versioning", "documentation/platform/audit-logs", + "documentation/platform/token", "documentation/platform/mfa", - "documentation/platform/token" + "documentation/platform/saml" ] }, { @@ -225,6 +226,7 @@ "integrations/cloud/checkly", "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", + "integrations/cloud/cloud-66", "integrations/cicd/githubactions", "integrations/cicd/gitlab", "integrations/cicd/circleci", diff --git a/docs/self-hosting/configuration/email.mdx b/docs/self-hosting/configuration/email.mdx index 15797d73b..160e4f205 100644 --- a/docs/self-hosting/configuration/email.mdx +++ b/docs/self-hosting/configuration/email.mdx @@ -10,7 +10,7 @@ However, the following functionality will be disabled. - Sending invite links via email for projects to teammates - Sending alerts such as suspicious login attempts -## General configuration +## Configuration If you choose to setup email service, you need to configure the following SMTP [environment variables](https://infisical.com/docs/self-hosting/configuration/envars): diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index f07415e41..61490758e 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -23,7 +23,8 @@ const integrationSlugNameMapping: Mapping = { "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", "digital-ocean-app-platform": "Digital Ocean App Platform", - bitbucket: "BitBucket" + bitbucket: "BitBucket", + "cloud-66": "Cloud 66" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Cloud 66.png b/frontend/public/images/integrations/Cloud 66.png new file mode 100644 index 000000000..20d841080 Binary files /dev/null and b/frontend/public/images/integrations/Cloud 66.png differ diff --git a/frontend/src/pages/integrations/cloud-66/authorize.tsx b/frontend/src/pages/integrations/cloud-66/authorize.tsx new file mode 100644 index 000000000..8c6434936 --- /dev/null +++ b/frontend/src/pages/integrations/cloud-66/authorize.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function Cloud66CreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + if (apiKey.length === 0) { + setApiKeyErrorText("Access token cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "cloud-66", + accessId: null, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/cloud-66/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Cloud 66 Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +Cloud66CreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/cloud-66/create.tsx b/frontend/src/pages/integrations/cloud-66/create.tsx new file mode 100644 index 000000000..dbf566464 --- /dev/null +++ b/frontend/src/pages/integrations/cloud-66/create.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById, +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function Cloud66CreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + Cloud 66 Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +Cloud66CreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/members/index.tsx b/frontend/src/pages/project/[id]/members/index.tsx index e3284ae9e..471c1bca7 100644 --- a/frontend/src/pages/project/[id]/members/index.tsx +++ b/frontend/src/pages/project/[id]/members/index.tsx @@ -183,7 +183,9 @@ export default function Users() {