diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e30c6b763..50115e58f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -108,6 +108,22 @@ brews: zsh_completion.install "completions/infisical.zsh" => "_infisical" fish_completion.install "completions/infisical.fish" man1.install "manpages/infisical.1.gz" + - name: 'infisical@{{.Version}}' + tap: + owner: Infisical + name: homebrew-get-cli + commit_author: + name: "Infisical" + email: ai@infisical.com + folder: Formula + homepage: "https://infisical.com" + description: "The official Infisical CLI" + install: |- + bin.install "infisical" + bash_completion.install "completions/infisical.bash" => "infisical" + zsh_completion.install "completions/infisical.zsh" => "_infisical" + fish_completion.install "completions/infisical.fish" + man1.install "manpages/infisical.1.gz" nfpms: - id: infisical diff --git a/README.md b/README.md index b0b964d37..a0d7798ce 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,17 @@ We're on a mission to make secret management more accessible to everyone, not ju ## Features -- **[User-friendly dashboard](https://infisical.com/docs/documentation/platform/project)** to manage secrets across projects and environments (e.g. development, production, etc.) +- **[User-friendly dashboard](https://infisical.com/docs/documentation/platform/project)** to manage secrets across projects and environments (e.g. development, production, etc.) - **[Client SDKs](https://infisical.com/docs/sdks/overview)** to fetch secrets for your apps and infrastructure on demand -- **[Infisical CLI](https://infisical.com/docs/cli/overview)** to fetch and inject secrets into any framework in local development -- **[Native integrations](https://infisical.com/docs/integrations/overview)** with platforms like GitHub, Vercel, Netlify, and more +- **[Infisical CLI](https://infisical.com/docs/cli/overview)** to fetch and inject secrets into any framework in local development +- **[Native integrations](https://infisical.com/docs/integrations/overview)** with platforms like GitHub, Vercel, Netlify, and more - [**Automatic Kubernetes deployment secret reloads**](https://infisical.com/docs/documentation/getting-started/kubernetes) -- **[Complete control over your data](https://infisical.com/docs/self-hosting/overview)** - host it yourself on any infrastructure -- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery]()** to version every secret and project state -- **[Audit logs](https://infisical.com/docs/documentation/platform/audit-logs)** to record every action taken in a project -- **Role-based Access Controls** per environment -- [**Simple on-premise deployments** to AWS, Digital Ocean, and more](https://infisical.com/docs/self-hosting/overview) -- [**Secret Scanning and Leak Prevention**](https://infisical.com/docs/cli/scanning-overview) +- **[Complete control over your data](https://infisical.com/docs/self-hosting/overview)** - host it yourself on any infrastructure +- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery]()** to version every secret and project state +- **[Audit logs](https://infisical.com/docs/documentation/platform/audit-logs)** to record every action taken in a project +- **Role-based Access Controls** per environment +- [**Simple on-premise deployments** to AWS, Digital Ocean, and more](https://infisical.com/docs/self-hosting/overview) +- [**Secret Scanning and Leak Prevention**](https://infisical.com/docs/cli/scanning-overview) And much more. @@ -72,8 +72,8 @@ And much more. Check out the [Quickstart Guides](https://infisical.com/docs/getting-started/introduction) -| Use Infisical Cloud | Deploy Infisical on premise | -| --- | ----------- | +| Use Infisical Cloud | Deploy Infisical on premise | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The fastest and most reliable way to
get started with Infisical is signing up
for free to [Infisical Cloud](https://app.infisical.com/login). | Deploy to DO
View all [deployment options](https://infisical.com/docs/self-hosting/overview) | ### Run Infisical locally @@ -83,7 +83,7 @@ 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.yml up +git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.yml up ``` Windows Command Prompt: @@ -94,8 +94,9 @@ git clone https://github.com/Infisical/infisical && cd infisical && copy .env.ex Create an account at `http://localhost:80` -### Scan and prevent secret leaks -On top managing secrets with Infisical, you can also [scan for over 140+ secret types]() in your files, directories and git repositories. +### Scan and prevent secret leaks + +On top managing secrets with Infisical, you can also [scan for over 140+ secret types]() in your files, directories and git repositories. To scan your full git history, run: @@ -103,20 +104,19 @@ To scan your full git history, run: infisical scan --verbose ``` -Install pre commit hook to scan each commit before you push to your repository +Install pre commit hook to scan each commit before you push to your repository ``` infisical scan install --pre-commit-hook ``` -Lean about Infisical's code scanning feature [here](https://infisical.com/docs/cli/scanning-overview) - +Lean about Infisical's code scanning feature [here](https://infisical.com/docs/cli/scanning-overview) ## Open-source vs. paid -This repo available under the [MIT expat license](https://github.com/Infisical/infisical/blob/main/LICENSE), with the exception of the `ee` directory which will contain premium enterprise features requiring a Infisical license. +This repo available under the [MIT expat license](https://github.com/Infisical/infisical/blob/main/LICENSE), with the exception of the `ee` directory which will contain premium enterprise features requiring a Infisical license. -If you are interested in managed Infisical Cloud of self-hosted Enterprise Offering, take a look at [our webiste](https://infisical.com/) or [book a meeting with us](https://cal.com/vmatsiiako/infisical-demo): +If you are interested in managed Infisical Cloud of self-hosted Enterprise Offering, take a look at [our webiste](https://infisical.com/) or [book a meeting with us](https://cal.com/vmatsiiako/infisical-demo): Schedule a meeting @@ -134,8 +134,8 @@ Whether it's big or small, we love contributions. Check out our guide to see how Not sure where to get started? You can: -- [Book a free, non-pressure pairing session / code walkthrough with one of our teammates](https://cal.com/tony-infisical/30-min-meeting-contributing)! -- Join our Slack, and ask us any questions there. +- [Book a free, non-pressure pairing session / code walkthrough with one of our teammates](https://cal.com/tony-infisical/30-min-meeting-contributing)! +- Join our Slack, and ask us any questions there. ## Resources @@ -156,4 +156,25 @@ Not sure where to get started? You can: - + + + + + + + + + + + + + + + + + + + + + + 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/v1/secretImportController.ts b/backend/src/controllers/v1/secretImportController.ts index a55b20a94..4b6539930 100644 --- a/backend/src/controllers/v1/secretImportController.ts +++ b/backend/src/controllers/v1/secretImportController.ts @@ -30,7 +30,6 @@ export const createSecretImport = async (req: Request, res: Response) => { if (doesImportExist) { throw BadRequestError({ message: "Secret import already exist" }); } - importSecDoc.imports.push({ environment: secretImport.environment, secretPath: secretImport.secretPath diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index ea8098e37..284c69c09 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -830,7 +830,7 @@ export const getSecrets = async (req: Request, res: Response) => { // TODO(akhilmhdh) - secret-imp change this to org type let importedSecrets: any[] = []; - if (include_imports) { + if (include_imports === "true") { importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId as string); } 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/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index cd2d3248e..8a190f43a 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -3,12 +3,15 @@ import { Types } from "mongoose"; import { EventService, SecretService } from "../../services"; import { eventPushSecrets } from "../../events"; import { BotService } from "../../services"; -import { repackageSecretToRaw } from "../../helpers/secrets"; +import { containsGlobPatterns, repackageSecretToRaw } from "../../helpers/secrets"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { getAllImportedSecrets } from "../../services/SecretImportService"; import Folder from "../../models/folder"; import { getFolderByPath } from "../../services/FolderService"; import { BadRequestError } from "../../utils/errors"; +import { IServiceTokenData } from "../../models"; +import { requireWorkspaceAuth } from "../../middleware"; +import { ADMIN, MEMBER, PERMISSION_READ_SECRETS } from "../../variables"; /** * Return secrets for workspace with id [workspaceId] and environment @@ -17,11 +20,34 @@ import { BadRequestError } from "../../utils/errors"; * @param res */ export const getSecretsRaw = async (req: Request, res: Response) => { - const workspaceId = req.query.workspaceId as string; - const environment = req.query.environment as string; - const secretPath = req.query.secretPath as string; + let workspaceId = req.query.workspaceId as string; + let environment = req.query.environment as string; + let secretPath = req.query.secretPath as string; const includeImports = req.query.include_imports as string; + // if the service token has single scope, it will get all secrets for that scope by default + const serviceTokenDetails: IServiceTokenData = req?.serviceTokenData; + if (serviceTokenDetails) { + if ( + serviceTokenDetails.scopes.length == 1 && + !containsGlobPatterns(serviceTokenDetails.scopes[0].secretPath) + ) { + const scope = serviceTokenDetails.scopes[0]; + secretPath = scope.secretPath; + environment = scope.environment; + workspaceId = serviceTokenDetails.workspace.toString(); + } else { + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "query", + locationEnvironment: "query", + requiredPermissions: [PERMISSION_READ_SECRETS], + requireBlindIndicesEnabled: true, + requireE2EEOff: true + }); + } + } + const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), environment, @@ -33,7 +59,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId) }); - if (includeImports) { + if (includeImports === "true") { const folders = await Folder.findOne({ workspace: workspaceId, environment }); let folderId = "root"; // if folder exist get it and replace folderid with new one @@ -271,7 +297,7 @@ export const getSecrets = async (req: Request, res: Response) => { authData: req.authData }); - if (includeImports) { + if (includeImports === "true") { const folders = await Folder.findOne({ workspace: workspaceId, environment }); let folderId = "root"; // if folder exist get it and replace folderid with new one diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index cca4bd970..0c63b41c0 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -2,6 +2,7 @@ import * as secretController from "./secretController"; import * as secretSnapshotController from "./secretSnapshotController"; import * as organizationsController from "./organizationsController"; import * as ssoController from "./ssoController"; +import * as usersController from "./usersController"; import * as workspaceController from "./workspaceController"; import * as actionController from "./actionController"; import * as membershipController from "./membershipController"; @@ -12,6 +13,7 @@ export { secretSnapshotController, organizationsController, ssoController, + usersController, workspaceController, actionController, membershipController, 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/ee/controllers/v1/usersController.ts b/backend/src/ee/controllers/v1/usersController.ts new file mode 100644 index 000000000..13e36a883 --- /dev/null +++ b/backend/src/ee/controllers/v1/usersController.ts @@ -0,0 +1,13 @@ +import { Request, Response } from "express"; + +/** + * Return the ip address of the current user + * @param req + * @param res + * @returns + */ +export const getMyIp = (req: Request, res: Response) => { + return res.status(200).send({ + ip: req.authData.authIP + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 5ec54d676..d4f097ea2 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -3,16 +3,20 @@ import { PipelineStage, Types } from "mongoose"; import { Secret } from "../../../models"; import { FolderVersion, + IPType, ISecretVersion, Log, SecretSnapshot, SecretVersion, TFolderRootVersionSchema, + TrustedIP } from "../../models"; import { EESecretService } from "../../services"; import { getLatestSecretVersionIds } from "../../helpers/secretVersion"; import Folder, { TFolderSchema } from "../../../models/folder"; import { searchByFolderId } from "../../../services/FolderService"; +import { EELicenseService } from "../../services"; +import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; /** * Return secret snapshots for workspace with id [workspaceId] @@ -588,3 +592,147 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { logs, }); }; + +/** + * Return trusted ips for workspace with id [workspaceId] + * @param req + * @param res + */ +export const getWorkspaceTrustedIps = async (req: Request, res: Response) => { + const { workspaceId } = req.params; + + const trustedIps = await TrustedIP.find({ + workspace: new Types.ObjectId(workspaceId) + }); + + return res.status(200).send({ + trustedIps + }); +} + +/** + * Add a trusted ip to workspace with id [workspaceId] + * @param req + * @param res + */ +export const addWorkspaceTrustedIp = async (req: Request, res: Response) => { + const { workspaceId } = req.params; + const { + ipAddress: ip, + comment, + isActive + } = req.body; + + const plan = await EELicenseService.getPlan(req.workspace.organization.toString()); + + if (!plan.ipAllowlisting) return res.status(400).send({ + message: "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(ip); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + const { ipAddress, type, prefix } = extractIPDetails(ip); + + const trustedIp = await new TrustedIP({ + workspace: new Types.ObjectId(workspaceId), + ipAddress, + type, + prefix, + isActive, + comment, + }).save(); + + return res.status(200).send({ + trustedIp + }); +} + +/** + * Update trusted ip with id [trustedIpId] workspace with id [workspaceId] + * @param req + * @param res + */ +export const updateWorkspaceTrustedIp = async (req: Request, res: Response) => { + const { workspaceId, trustedIpId } = req.params; + const { + ipAddress: ip, + comment + } = req.body; + + const plan = await EELicenseService.getPlan(req.workspace.organization.toString()); + + if (!plan.ipAllowlisting) return res.status(400).send({ + message: "Failed to update IP access range due to plan restriction. Upgrade plan to update IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(ip); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + const { ipAddress, type, prefix } = extractIPDetails(ip); + + const updateObject: { + ipAddress: string; + type: IPType; + comment: string; + prefix?: number; + $unset?: { + prefix: number; + } + } = { + ipAddress, + type, + comment + }; + + if (prefix !== undefined) { + updateObject.prefix = prefix; + } else { + updateObject.$unset = { prefix: 1 }; + } + + const trustedIp = await TrustedIP.findOneAndUpdate( + { + _id: new Types.ObjectId(trustedIpId), + workspace: new Types.ObjectId(workspaceId), + }, + updateObject, + { + new: true + } + ); + + return res.status(200).send({ + trustedIp + }); +} + +/** + * Delete IP access range from workspace with id [workspaceId] + * @param req + * @param res + */ +export const deleteWorkspaceTrustedIp = async (req: Request, res: Response) => { + const { workspaceId, trustedIpId } = req.params; + + const plan = await EELicenseService.getPlan(req.workspace.organization.toString()); + + if (!plan.ipAllowlisting) return res.status(400).send({ + message: "Failed to delete IP access range due to plan restriction. Upgrade plan to delete IP access range." + }); + + const trustedIp = await TrustedIP.findOneAndDelete({ + _id: new Types.ObjectId(trustedIpId), + workspace: new Types.ObjectId(workspaceId) + }); + + return res.status(200).send({ + trustedIp + }); +} \ No newline at end of file diff --git a/backend/src/ee/models/action.ts b/backend/src/ee/models/action.ts index 2e8432914..c763915ee 100644 --- a/backend/src/ee/models/action.ts +++ b/backend/src/ee/models/action.ts @@ -66,6 +66,4 @@ const actionSchema = new Schema( } ); -const Action = model("Action", actionSchema); - -export default Action; \ No newline at end of file +export const Action = model("Action", actionSchema); \ No newline at end of file diff --git a/backend/src/ee/models/folderVersion.ts b/backend/src/ee/models/folderVersion.ts index 4bfa2f67c..dbcebcb92 100644 --- a/backend/src/ee/models/folderVersion.ts +++ b/backend/src/ee/models/folderVersion.ts @@ -52,9 +52,7 @@ const folderRootVersionSchema = new Schema( } ); -const FolderVersion = model( +export const FolderVersion = model( "FolderVersion", folderRootVersionSchema -); - -export default FolderVersion; +); \ No newline at end of file diff --git a/backend/src/ee/models/index.ts b/backend/src/ee/models/index.ts index 2a4686617..1def9073d 100644 --- a/backend/src/ee/models/index.ts +++ b/backend/src/ee/models/index.ts @@ -1,21 +1,7 @@ -import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot"; -import SecretVersion, { ISecretVersion } from "./secretVersion"; -import FolderVersion, { TFolderRootVersionSchema } from "./folderVersion"; -import Log, { ILog } from "./log"; -import Action, { IAction } from "./action"; -import SSOConfig, { ISSOConfig } from "./ssoConfig"; - -export { - SecretSnapshot, - ISecretSnapshot, - SecretVersion, - ISecretVersion, - FolderVersion, - TFolderRootVersionSchema, - Log, - ILog, - Action, - IAction, - SSOConfig, - ISSOConfig -}; +export * from "./secretSnapshot"; +export * from "./secretVersion"; +export * from "./folderVersion"; +export * from "./log"; +export * from "./action"; +export * from "./ssoConfig"; +export * from "./trustedIp"; \ No newline at end of file diff --git a/backend/src/ee/models/log.ts b/backend/src/ee/models/log.ts index c1c11be41..ed8ff17f7 100644 --- a/backend/src/ee/models/log.ts +++ b/backend/src/ee/models/log.ts @@ -69,6 +69,4 @@ const logSchema = new Schema( } ); -const Log = model("Log", logSchema); - -export default Log; \ No newline at end of file +export const Log = model("Log", logSchema); \ No newline at end of file diff --git a/backend/src/ee/models/secretSnapshot.ts b/backend/src/ee/models/secretSnapshot.ts index d0fb61110..71d1b27e6 100644 --- a/backend/src/ee/models/secretSnapshot.ts +++ b/backend/src/ee/models/secretSnapshot.ts @@ -46,9 +46,7 @@ const secretSnapshotSchema = new Schema( } ); -const SecretSnapshot = model( +export const SecretSnapshot = model( "SecretSnapshot", secretSnapshotSchema -); - -export default SecretSnapshot; +); \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 1922d4539..d63f05cf7 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -124,9 +124,7 @@ const secretVersionSchema = new Schema( } ); -const SecretVersion = model( +export const SecretVersion = model( "SecretVersion", secretVersionSchema -); - -export default SecretVersion; +); \ No newline at end of file diff --git a/backend/src/ee/models/ssoConfig.ts b/backend/src/ee/models/ssoConfig.ts index fdd3c466b..ab870afd7 100644 --- a/backend/src/ee/models/ssoConfig.ts +++ b/backend/src/ee/models/ssoConfig.ts @@ -77,6 +77,4 @@ const ssoConfigSchema = new Schema( } ); -const SSOConfig = model("SSOConfig", ssoConfigSchema); - -export default SSOConfig; \ No newline at end of file +export const SSOConfig = model("SSOConfig", ssoConfigSchema); \ No newline at end of file diff --git a/backend/src/ee/models/trustedIp.ts b/backend/src/ee/models/trustedIp.ts new file mode 100644 index 000000000..85616be11 --- /dev/null +++ b/backend/src/ee/models/trustedIp.ts @@ -0,0 +1,54 @@ +import { Schema, Types, model } from "mongoose"; + +export enum IPType { + IPV4 = "ipv4", + IPV6 = "ipv6" +} + +export interface ITrustedIP { + _id: Types.ObjectId; + workspace: Types.ObjectId; + ipAddress: string; + type: "ipv4" | "ipv6", // either IPv4/IPv6 address or network IPv4/IPv6 address + isActive: boolean; + comment: string; + prefix?: number; // CIDR +} + +const trustedIpSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + ipAddress: { + type: String, + required: true + }, + type: { + type: String, + enum: [ + IPType.IPV4, + IPType.IPV6 + ], + required: true + }, + prefix: { + type: Number, + required: false + }, + isActive: { + type: Boolean, + required: true + }, + comment: { + type: String + } + }, + { + timestamps: true + } +); + +export const TrustedIP = model("TrustedIP", trustedIpSchema); \ No newline at end of file diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index a40418996..cf92bfc6c 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -2,6 +2,7 @@ import secret from "./secret"; import secretSnapshot from "./secretSnapshot"; import organizations from "./organizations"; import sso from "./sso"; +import users from "./users"; import workspace from "./workspace"; import action from "./action"; import cloudProducts from "./cloudProducts"; @@ -11,6 +12,7 @@ export { secretSnapshot, organizations, sso, + users, workspace, action, cloudProducts, diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index fb26e1064..4771b5144 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -18,10 +18,15 @@ import { router.get( "/redirect/google", authLimiter, - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - }) + (req, res, next) => { + passport.authenticate("google", { + scope: ["profile", "email"], + session: false, + ...(req.query.callback_port ? { + state: req.query.callback_port as string + } : {}) + })(req, res, next); + } ); router.get( @@ -36,16 +41,22 @@ router.get( router.get( "/redirect/saml2/:ssoIdentifier", authLimiter, - passport.authenticate("saml", { - failureRedirect: "/login/fail" - }) + (req, res, next) => { + const options = { + failureRedirect: "/", + additionalParams: { + RelayState: req.query.callback_port ?? "" + }, + }; + passport.authenticate("saml", options)(req, res, next); + } ); router.post("/saml2/:ssoIdentifier", passport.authenticate("saml", { failureRedirect: "/login/provider/error", failureFlash: true, - session: false + session: false }), ssoController.redirectSSO ); diff --git a/backend/src/ee/routes/v1/users.ts b/backend/src/ee/routes/v1/users.ts new file mode 100644 index 000000000..14dcaa49c --- /dev/null +++ b/backend/src/ee/routes/v1/users.ts @@ -0,0 +1,17 @@ +import express from "express"; +const router = express.Router(); +import { + requireAuth +} from "../../../middleware"; +import { AUTH_MODE_API_KEY, AUTH_MODE_JWT } from "../../../variables"; +import { usersController } from "../../controllers/v1"; + +router.get( + "/me/ip", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], + }), + usersController.getMyIp +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 40392b45f..49b13e6a5 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -6,13 +6,18 @@ import { validateRequest, } from "../../../middleware"; import { body, param, query } from "express-validator"; -import { ADMIN, MEMBER } from "../../../variables"; +import { + ADMIN, + AUTH_MODE_API_KEY, + AUTH_MODE_JWT, + MEMBER +} from "../../../variables"; import { workspaceController } from "../../controllers/v1"; router.get( "/:workspaceId/secret-snapshots", requireAuth({ - acceptedAuthModes: ["jwt", "apiKey"], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -30,7 +35,7 @@ router.get( router.get( "/:workspaceId/secret-snapshots/count", requireAuth({ - acceptedAuthModes: ["jwt"], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -46,7 +51,7 @@ router.get( router.post( "/:workspaceId/secret-snapshots/rollback", requireAuth({ - acceptedAuthModes: ["jwt", "apiKey"], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -63,7 +68,7 @@ router.post( router.get( "/:workspaceId/logs", requireAuth({ - acceptedAuthModes: ["jwt", "apiKey"], + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -79,4 +84,66 @@ router.get( workspaceController.getWorkspaceLogs ); +router.get( + "/:workspaceId/trusted-ips", + param("workspaceId").exists().isString().trim(), + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "params", + }), + workspaceController.getWorkspaceTrustedIps +); + +router.post( + "/:workspaceId/trusted-ips", + param("workspaceId").exists().isString().trim(), + body("ipAddress").exists().isString().trim(), + body("comment").default("").isString().trim(), + body("isActive").exists().isBoolean(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: "params", + }), + workspaceController.addWorkspaceTrustedIp +); + +router.patch( + "/:workspaceId/trusted-ips/:trustedIpId", + param("workspaceId").exists().isString().trim(), + param("trustedIpId").exists().isString().trim(), + body("ipAddress").isString().trim().default(""), + body("comment").default("").isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: "params", + }), + workspaceController.updateWorkspaceTrustedIp +); + +router.delete( + "/:workspaceId/trusted-ips/:trustedIpId", + param("workspaceId").exists().isString().trim(), + param("trustedIpId").exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: "params", + }), + workspaceController.deleteWorkspaceTrustedIp +); + export default router; diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts index e08e77c04..12e3e496a 100644 --- a/backend/src/ee/services/EELicenseService.ts +++ b/backend/src/ee/services/EELicenseService.ts @@ -26,6 +26,7 @@ interface FeatureSet { environmentsUsed: number; secretVersioning: boolean; pitRecovery: boolean; + ipAllowlisting: boolean; rbac: boolean; customRateLimits: boolean; customAlerts: boolean; @@ -60,6 +61,7 @@ class EELicenseService { environmentsUsed: 0, secretVersioning: true, pitRecovery: false, + ipAllowlisting: false, rbac: true, customRateLimits: true, customAlerts: true, 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/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 1a68930bc..c770e78fb 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -44,6 +44,7 @@ import { EELogService, EESecretService } from "../ee/services"; import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/auth"; import { getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; +import path from "path"; export const isValidScope = ( authPayload: IServiceTokenData, @@ -60,6 +61,13 @@ export const isValidScope = ( return Boolean(validScope); }; +export function containsGlobPatterns(secretPath: string) { + const globChars = ["*", "?", "[", "]", "{", "}", "**"]; + const normalizedPath = path.normalize(secretPath); + return globChars.some(char => normalizedPath.includes(char)); +} + + /** * Returns an object containing secret [secret] but with its value, key, comment decrypted. * diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index d79d6c453..0f4e5dc6b 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -5,6 +5,10 @@ import { Secret, Workspace, } from "../models"; +import { + IPType, + TrustedIP +} from "../ee/models"; import { createBot } from "../helpers/bot"; import { EELicenseService } from "../ee/services"; import { SecretService } from "../services"; @@ -40,6 +44,16 @@ export const createWorkspace = async ({ await SecretService.createSecretBlindIndexData({ workspaceId: workspace._id, }); + + // initialize default trusted ip of 0.0.0.0/0 + await new TrustedIP({ + workspace: workspace._id, + ipAddress: "0.0.0.0", + type: IPType.IPV4, + prefix: 0, + isActive: true, + comment: "" + }).save() await EELicenseService.refreshPlan(organizationId); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0235e601d..098e7fc27 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,7 +22,8 @@ import { sso as eeSSORouter, secret as eeSecretRouter, secretSnapshot as eeSecretSnapshotRouter, - workspace as eeWorkspaceRouter + users as eeUsersRouter, + workspace as eeWorkspaceRouter, } from "./ee/routes/v1"; import { auth as v1AuthRouter, @@ -129,6 +130,7 @@ const main = async () => { // (EE) routes app.use("/api/v1/secret", eeSecretRouter); app.use("/api/v1/secret-snapshot", eeSecretSnapshotRouter); + app.use("/api/v1/users", eeUsersRouter); app.use("/api/v1/workspace", eeWorkspaceRouter); app.use("/api/v1/action", eeActionRouter); app.use("/api/v1/organizations", eeOrganizationsRouter); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 8202a9d02..d2a262b17 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,8 +10,12 @@ 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, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, INTEGRATION_FLYIO_API_URL, INTEGRATION_GITHUB, @@ -34,11 +35,16 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, 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; @@ -132,6 +138,12 @@ const getApps = async ({ serverId: accessId }); break; + case INTEGRATION_TERRAFORM_CLOUD: + apps = await getAppsTerraformCloud({ + accessToken, + workspacesId: accessId, + }); + break; case INTEGRATION_TRAVISCI: apps = await getAppsTravisCI({ accessToken, @@ -169,6 +181,16 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM: + apps = await getAppsDigitalOceanAppPlatform({ + accessToken + }); + break; + case INTEGRATION_CLOUD_66: + apps = await getAppsCloud66({ + accessToken, + }); + break; } return apps; @@ -556,6 +578,43 @@ const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { return apps; }; +/** + * Return list of projects for Terraform Cloud integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Terraform Cloud API + * @param {String} obj.workspacesId - workspace id of Terraform Cloud projects + * @returns {Object[]} apps - names and ids of Terraform Cloud projects + * @returns {String} apps.name - name of Terraform Cloud projects + */ +const getAppsTerraformCloud = async ({ + accessToken, + workspacesId +}: { + accessToken: string; + workspacesId?: string; +}) => { + const res = ( + await standardRequest.get(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${workspacesId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.data; + + const apps = [] + + const appsObj = { + name: res?.attributes.name, + appId: res?.id, + }; + + apps.push(appsObj) + + return apps; +}; + + /** * Return list of repositories for GitLab integration * @param {Object} obj @@ -884,7 +943,6 @@ const getAppsNorthflank = async ({ accessToken }: { accessToken: string }) => { * @returns {Object[]} apps - names of Supabase apps * @returns {String} apps.name - name of Supabase app */ - const getAppsCodefresh = async ({ accessToken, }: { @@ -907,4 +965,106 @@ const getAppsCodefresh = async ({ return apps; }; + +/** + * 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; + owner_uuid: string; + spec: Spec; + } + + interface Spec { + name: string; + region: string; + envs: Env[]; + } + + interface Env { + key: string; + value: string; + scope: string; + } + + const res = ( + await standardRequest.get(`${INTEGRATION_DIGITAL_OCEAN_API_URL}/v2/apps`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).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 0d9a5cbd7..715e31025 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,8 +18,12 @@ 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, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, INTEGRATION_FLYIO_API_URL, INTEGRATION_GITHUB, @@ -44,11 +44,17 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, 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"; /** @@ -191,6 +197,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_TERRAFORM_CLOUD: + await syncSecretsTerraformCloud({ + integration, + secrets, + accessToken, + }); + break; case INTEGRATION_HASHICORP_VAULT: await syncSecretsHashiCorpVault({ integration, @@ -222,6 +235,20 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM: + await syncSecretsDigitalOceanAppPlatform({ + integration, + secrets, + accessToken, + }); + break; + case INTEGRATION_CLOUD_66: + await syncSecretsCloud66({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_NORTHFLANK: await syncSecretsNorthflank({ integration, @@ -1831,6 +1858,106 @@ const syncSecretsCheckly = async ({ } }; +/** + * Sync/push [secrets] to Terraform Cloud project with id [integration.appId] + * @param {Object} obj + * @param {IIntegration} 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 Terraform Cloud API + */ +const syncSecretsTerraformCloud = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + // get secrets from Terraform Cloud + const getSecretsRes = ( + await standardRequest.get(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + )) + .data + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.attributes.key]: secret + }), {}); + + // create or update secrets on Terraform Cloud + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in Terraform Cloud + // -> add secret + await standardRequest.post( + `${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, + { + data: { + type: "vars", + attributes: { + key, + value: secrets[key], + category: integration.targetService, + }, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + } + ); + } else { + // case: secret exists in Terraform Cloud + if (secrets[key] !== getSecretsRes[key].attributes.value) { + // -> update secret + await standardRequest.patch( + `${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + { + data: { + type: "vars", + id: getSecretsRes[key].id, + attributes: { + ...getSecretsRes[key], + value: secrets[key] + }, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + } + ); + } + } + } + + for await (const key of Object.keys(getSecretsRes)) { + if (!(key in secrets)) { + // case: delete secret + await standardRequest.delete(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + }) + } + } +}; + /** * Sync/push [secrets] to HashiCorp Vault path * @param {Object} obj @@ -2077,10 +2204,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 +2238,143 @@ 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, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + await standardRequest.put( + `${INTEGRATION_DIGITAL_OCEAN_API_URL}/v2/apps/${integration.appId}`, + { + spec: { + name: integration.app, + envs: Object.entries(secrets).map(([key, value]) => ({ key, value })) + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); +} + +/** + * 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" + } + } + ); + } + } +}; + /* Sync/push [secrets] to Northflank * @param {Object} obj * @param {IIntegration} obj.integration - integration details diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 197995a65..f6f7405a9 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -18,6 +18,7 @@ const requireWorkspaceAuth = ({ requiredPermissions = [], requireBlindIndicesEnabled = false, requireE2EEOff = false, + checkIPAllowlist = false }: { acceptedRoles: Array<"admin" | "member">; locationWorkspaceId: req; @@ -25,6 +26,7 @@ const requireWorkspaceAuth = ({ requiredPermissions?: string[]; requireBlindIndicesEnabled?: boolean; requireE2EEOff?: boolean; + checkIPAllowlist?: boolean; }) => { return async (req: Request, res: Response, next: NextFunction) => { const workspaceId = req[locationWorkspaceId]?.workspaceId; @@ -39,6 +41,7 @@ const requireWorkspaceAuth = ({ requiredPermissions, requireBlindIndicesEnabled, requireE2EEOff, + checkIPAllowlist }); if (membership) { diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 8400ab819..8608aa964 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,7 +6,9 @@ import { INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, INTEGRATION_GITHUB, INTEGRATION_GITLAB, @@ -19,9 +20,11 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Schema, Types, model } from "mongoose"; export interface IIntegration { _id: Types.ObjectId; @@ -57,10 +60,13 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "terraform-cloud" | "hashicorp-vault" | "cloudflare-pages" | "bitbucket" | "codefresh" + | "digital-ocean-app-platform" + | "cloud-66" | "northflank" integrationAuth: Types.ObjectId; } @@ -149,10 +155,13 @@ const integrationSchema = new Schema( INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, INTEGRATION_NORTHFLANK ], required: true, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index d36eee47f..dd8d0cd19 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,7 +8,9 @@ import { INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_FLYIO, INTEGRATION_GITHUB, INTEGRATION_GITLAB, @@ -21,9 +22,11 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Document, Schema, Types, model } from "mongoose"; export interface IIntegrationAuth extends Document { _id: Types.ObjectId; @@ -47,7 +50,10 @@ export interface IIntegrationAuth extends Document { | "checkly" | "cloudflare-pages" | "codefresh" + | "digital-ocean-app-platform" | "bitbucket" + | "cloud-66" + | "terraform-cloud" | "northflank"; teamId: string; accountId: string; @@ -92,10 +98,13 @@ const integrationAuthSchema = new Schema( INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, INTEGRATION_NORTHFLANK ], required: true, diff --git a/backend/src/routes/v1/secretImport.ts b/backend/src/routes/v1/secretImport.ts index 60ed16bc8..655fbd933 100644 --- a/backend/src/routes/v1/secretImport.ts +++ b/backend/src/routes/v1/secretImport.ts @@ -46,7 +46,7 @@ router.delete( body("secretImportPath").isString().exists().trim(), body("secretImportEnv").isString().exists().trim(), validateRequest, - secretImportController.updateSecretImport + secretImportController.deleteSecretImport ); router.get( diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index ca27d5b57..94d912b48 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -93,7 +93,7 @@ router.delete( usersController.deleteAPIKey ); -router.get( // new +router.get( "/me/sessions", requireAuth({ acceptedAuthModes: [AUTH_MODE_JWT], @@ -101,7 +101,7 @@ router.get( // new usersController.getMySessions ); -router.delete( // new +router.delete( "/me/sessions", requireAuth({ acceptedAuthModes: [AUTH_MODE_JWT], diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 69ba938e6..6d3b4911d 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -18,8 +18,8 @@ import { router.get( "/raw", - query("workspaceId").exists().isString().trim(), - query("environment").exists().isString().trim(), + query("workspaceId").optional().isString().trim(), + query("environment").optional().isString().trim(), query("secretPath").default("/").isString().trim(), query("include_imports").optional().isBoolean().default(false), validateRequest, @@ -31,14 +31,6 @@ router.get( AUTH_MODE_SERVICE_ACCOUNT ] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true - }), secretsController.getSecretsRaw ); @@ -64,7 +56,8 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.getSecretByNameRaw ); @@ -92,7 +85,8 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.createSecretRaw ); @@ -120,7 +114,8 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.updateSecretByNameRaw ); @@ -147,7 +142,8 @@ router.delete( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.deleteSecretByNameRaw ); @@ -172,7 +168,8 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.getSecrets ); @@ -207,7 +204,8 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.createSecret ); @@ -233,7 +231,8 @@ router.get( locationWorkspaceId: "query", locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true + requireBlindIndicesEnabled: true, + checkIPAllowlist: true }), secretsController.getSecretByName ); @@ -263,7 +262,8 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.updateSecretByName ); @@ -290,7 +290,8 @@ router.delete( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.deleteSecretByName ); diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index dccd5b5e4..ded2f3eb3 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -114,7 +114,10 @@ const initializePassport = async () => { firstName, lastName, authProvider: user.authProvider, - isUserCompleted + isUserCompleted, + ...(req.query.state ? { + callbackPort: req.query.state as string + } : {}) }, expiresIn: await getJwtProviderAuthLifetime(), secret: await getJwtProviderAuthSecret(), @@ -153,7 +156,6 @@ const initializePassport = async () => { }, }, async (req: any, profile: any, done: any) => { - if (!req.ssoConfig.isActive) return done(InternalServerError()); const organization = await Organization.findById(req.ssoConfig.organization); @@ -199,7 +201,10 @@ const initializePassport = async () => { lastName, organizationName: organization?.name, authProvider: user.authProvider, - isUserCompleted + isUserCompleted, + ...(req.body.RelayState ? { + callbackPort: req.body.RelayState as string + } : {}) }, expiresIn: await getJwtProviderAuthLifetime(), secret: await getJwtProviderAuthSecret(), diff --git a/backend/src/utils/ip/index.ts b/backend/src/utils/ip/index.ts new file mode 100644 index 000000000..17c8ce5a6 --- /dev/null +++ b/backend/src/utils/ip/index.ts @@ -0,0 +1 @@ +export * from "./ip"; \ No newline at end of file diff --git a/backend/src/utils/ip/ip.ts b/backend/src/utils/ip/ip.ts new file mode 100644 index 000000000..ac3b17149 --- /dev/null +++ b/backend/src/utils/ip/ip.ts @@ -0,0 +1,101 @@ +import net from "net"; +import { IPType } from "../../ee/models"; +import { InternalServerError } from "../errors"; + +/** + * Return details of IP [ip]: + * - If [ip] is a specific IP address then return the IPv4/IPv6 address + * - If [ip] is a subnet then return the network IPv4/IPv6 address and prefix + * @param {String} ip - ip whose details to return + * @returns + */ +export const extractIPDetails = (ip: string) => { + if (net.isIPv4(ip)) return ({ + ipAddress: ip, + type: IPType.IPV4 + }); + + if (net.isIPv6(ip)) return ({ + ipAddress: ip, + type: IPType.IPV6 + }); + + const [ipNet, prefix] = ip.split("/"); + + let type; + switch (net.isIP(ipNet)) { + case 4: + type = IPType.IPV4; + break; + case 6: + type = IPType.IPV6; + break; + default: + throw InternalServerError({ + message: "Failed to extract IP details" + }); + } + + return ({ + ipAddress: ipNet, + type, + prefix: parseInt(prefix, 10) + }); +} + +/** + * Checks if a given string is a valid CIDR block. + * + * The function checks if the input string is a valid IPv4 or IPv6 address in CIDR notation. + * + * CIDR notation includes a network address followed by a slash ('/') and a prefix length. + * For IPv4, the prefix length must be between 0 and 32. For IPv6, it must be between 0 and 128. + * If the input string is not a valid CIDR block, the function returns `false`. + * + * @param {string} cidr - string in CIDR notation + * @returns {boolean} Returns `true` if the string is a valid CIDR block, `false` otherwise. + * +*/ +export const isValidCidr = (cidr: string): boolean => { + const [ip, prefix] = cidr.split("/"); + + const prefixNum = parseInt(prefix, 10); + + // ensure prefix exists and is a number within the appropriate range for each IP version + if (!prefix || isNaN(prefixNum) || + (net.isIPv4(ip) && (prefixNum < 0 || prefixNum > 32)) || + (net.isIPv6(ip) && (prefixNum < 0 || prefixNum > 128))) { + return false; + } + + // ensure the IP portion of the CIDR block is a valid IPv4 or IPv6 address + if (!net.isIPv4(ip) && !net.isIPv6(ip)) { + return false; + } + + return true; +} + +/** + * Checks if a given string is a valid IPv4/IPv6 address or a valid CIDR block. + * + * If the string contains a slash ('/'), it treats the input as a CIDR block and checks its validity. + * Otherwise, it treats the string as a standalone IP address (either IPv4 or IPv6) and checks its validity. + * + * @param {string} input - The string to be checked. It could be an IP address or a CIDR block. + * @returns {boolean} Returns `true` if the string is a valid IP address (either IPv4 or IPv6) or a valid CIDR block, `false` otherwise. + * +*/ +export const isValidIpOrCidr = (ip: string): boolean => { + // if the string contains a slash, treat it as a CIDR block + if (ip.includes("/")) { + return isValidCidr(ip); + } + + // otherwise, treat it as a standalone IP address + if (net.isIPv4(ip) || net.isIPv6(ip)) { + return true; + } + + return false; +} \ No newline at end of file diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index 919b6c0ce..56ee59a8a 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -3,7 +3,13 @@ import crypto from "crypto"; import { Types } from "mongoose"; import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto"; import { EESecretService } from "../../ee/services"; -import { ISecretVersion, SecretSnapshot, SecretVersion } from "../../ee/models"; +import { + IPType, + ISecretVersion, + SecretSnapshot, + SecretVersion, + TrustedIP +} from "../../ee/models"; import { BackupPrivateKey, Bot, @@ -177,7 +183,6 @@ export const backfillBotOrgs = async () => { return new BotOrg({ name: "Infisical Bot", organization: organizationToAddBot, - isActive: false, publicKey, encryptedSymmetricKey, symmetricKeyIV, @@ -212,7 +217,6 @@ export const backfillBotOrgs = async () => { return new BotOrg({ name: "Infisical Bot", organization: organizationToAddBot, - isActive: false, publicKey, encryptedSymmetricKey, symmetricKeyIV, @@ -551,3 +555,41 @@ export const backfillServiceTokenMultiScope = async () => { console.log("Migration: Service token migration v2 complete"); }; + +/** + * Backfill each workspace without any registered trusted IPs to + * have default trusted ip of 0.0.0.0/0 + */ +export const backfillTrustedIps = async () => { + const workspaceIdsWithTrustedIps = await TrustedIP.distinct("workspace"); + const workspaceIdsToAddTrustedIp = await Workspace.distinct("_id", { + _id: { + $nin: workspaceIdsWithTrustedIps + } + }); + + if (workspaceIdsToAddTrustedIp.length > 0) { + const operations = workspaceIdsToAddTrustedIp.map((workspaceId) => { + return { + updateOne: { + filter: { + workspace: workspaceId, + ipAddress: "0.0.0.0" + }, + update: { + workspace: workspaceId, + ipAddress: "0.0.0.0", + type: IPType.IPV4.toString(), + prefix: 0, + isActive: true, + comment: "" + }, + upsert: true, + }, + }; + }); + + await TrustedIP.bulkWrite(operations); + console.log("Backfill: Trusted IPs complete"); + } +} diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index 4e9bd0b48..2f88b2913 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -15,7 +15,8 @@ import { backfillSecretFolders, backfillSecretVersions, backfillServiceToken, - backfillServiceTokenMultiScope + backfillServiceTokenMultiScope, + backfillTrustedIps } from "./backfillData"; import { reencryptBotOrgKeys, @@ -84,6 +85,7 @@ export const setup = async () => { await backfillServiceToken(); await backfillIntegration(); await backfillServiceTokenMultiScope(); + await backfillTrustedIps(); // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index cdc2771f4..505b6a425 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -1,14 +1,15 @@ +import net from "net"; import { Types } from "mongoose"; import { - IServiceAccount, - IServiceTokenData, - IUser, SecretBlindIndexData, ServiceAccount, ServiceTokenData, User, Workspace, } from "../models"; +import { + TrustedIP +} from "../ee/models"; import { validateServiceAccountClientForWorkspace } from "./serviceAccount"; import { validateUserClientForWorkspace } from "./user"; import { validateServiceTokenDataClientForWorkspace } from "./serviceTokenData"; @@ -24,6 +25,7 @@ import { AUTH_MODE_SERVICE_TOKEN, } from "../variables"; import { BotService } from "../services"; +import { AuthData } from "../interfaces/middleware"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -43,17 +45,16 @@ export const validateClientForWorkspace = async ({ requiredPermissions, requireBlindIndicesEnabled, requireE2EEOff, + checkIPAllowlist }: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; + authData: AuthData; workspaceId: Types.ObjectId; environment?: string; acceptedRoles: Array<"admin" | "member">; requiredPermissions?: string[]; requireBlindIndicesEnabled: boolean; requireE2EEOff: boolean; + checkIPAllowlist: boolean; }) => { const workspace = await Workspace.findById(workspaceId); @@ -82,6 +83,8 @@ export const validateClientForWorkspace = async ({ message: "Failed workspace authorization due to end-to-end encryption not being disabled", }); } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { const membership = await validateUserClientForWorkspace({ @@ -107,6 +110,39 @@ export const validateClientForWorkspace = async ({ } if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + if (checkIPAllowlist) { + const trustedIps = await TrustedIP.find({ + workspace: workspaceId + }); + + if (trustedIps.length > 0) { + // case: check the IP address of the inbound request against trusted IPs + + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet( + trustedIp.ipAddress, + trustedIp.prefix, + trustedIp.type + ); + } else { + blockList.addAddress( + trustedIp.ipAddress, + trustedIp.type + ); + } + } + + const check = blockList.check(authData.authIP); + + if (!check) throw UnauthorizedRequestError({ + message: "Failed workspace authorization" + }); + } + } + await validateServiceTokenDataClientForWorkspace({ serviceTokenData: authData.authPayload, workspaceId, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index b9595cbcf..42066e3e5 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -25,10 +25,13 @@ export const INTEGRATION_CIRCLECI = "circleci"; export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; +export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; 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_NORTHFLANK = "northflank"; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, @@ -44,10 +47,13 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, INTEGRATION_NORTHFLANK ]); @@ -78,9 +84,12 @@ export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; +export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; 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 INTEGRATION_NORTHFLANK_API_URL = "https://api.northflank.com"; export const getIntegrationOptions = async () => { @@ -203,6 +212,15 @@ export const getIntegrationOptions = async () => { clientId: await getClientIdGitLab(), docsLink: "", }, + { + name: "Terraform Cloud", + slug: "terraform-cloud", + image: "Terraform Cloud.png", + isAvailable: true, + type: "pat", + cliendId: "", + docsLink: "", + }, { name: "Travis CI", slug: "travisci", @@ -275,6 +293,24 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Digital Ocean App Platform", + slug: "digital-ocean-app-platform", + image: "Digital Ocean.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, + { + name: "Cloud 66", + slug: "cloud-66", + image: "Cloud 66.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "Northflank", slug: "northflank", @@ -283,7 +319,7 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" - } + }, ] return INTEGRATION_OPTIONS; diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 02fe16327..906723df8 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -143,13 +143,13 @@ var runCmd = &cobra.Command{ err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env) if err != nil { - util.HandleError(err, "Unable to execute your chained command") + fmt.Println(err) } } else { err = executeSingleCommandWithEnvs(args, len(secretsByKey), env) if err != nil { - util.HandleError(err, "Unable to execute your single command") + fmt.Println(err) } } }, diff --git a/docs/api-reference/overview/examples/e2ee-disabled.mdx b/docs/api-reference/overview/examples/e2ee-disabled.mdx index 9864a1ff5..1a9e57552 100644 --- a/docs/api-reference/overview/examples/e2ee-disabled.mdx +++ b/docs/api-reference/overview/examples/e2ee-disabled.mdx @@ -7,8 +7,7 @@ in plaintext. Effectively, this means each such secret operation only requires 1 - Retrieve all secrets for an Infisical project and environment. - + Retrieve all secrets for an Infisical project and environment. ```bash @@ -18,7 +17,12 @@ in plaintext. Effectively, this means each such secret operation only requires 1 ``` - + #### + + When using a [service token](../../../documentation/platform/token) with access to a single environment and path, you don't need to provide request parameters because the server will automatically scope the request to the defined environment/secrets path of the service token used. + For all other cases, request parameters are required. + + #### The ID of the workspace diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index 1394d12df..d4982938f 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -6,19 +6,26 @@ The changelog below reflects new product developments and updates on a monthly b ## July 2023 -- Released [secret referencing and importing](https://infisical.com/docs/documentation/platform/secret-reference) across folders and environments. -- Added the [intergation with Laravel Forge](https://infisical.com/docs/integrations/cloud/laravel-forge). +- Released [secret referencing and importing](https://infisical.com/docs/documentation/platform/secret-reference) across folders and environments. - Redesigned the project/organization experience. +- Added native [Laravel Forge integration](https://infisical.com/docs/integrations/cloud/laravel-forge). +- Added native [Codefresh integration](https://infisical.com/docs/integrations/cicd/codefresh) +- Added native [Bitbucket integration](https://infisical.com/docs/integrations/cicd/bitbucket) +- Added native [DigitalOcean App Platform integration](https://infisical.com/docs/integrations/cloud/digital-ocean-app-platform) +- Added native [Cloud66 integration](https://infisical.com/docs/integrations/cloud/cloud-66) +- Added support for Google SSO. +- Added support for [Okta SAML 2.0 authentication](https://infisical.com/docs/documentation/platform/saml) +- Released [folders / path-based secret storage](https://infisical.com/docs/documentation/platform/folder) +- Released [webhooks](https://infisical.com/docs/documentation/platform/webhooks) ## June 2023 - Released the [Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform#5-run-terraform). - Updated the usage and billing page. Added the free trial for the professional tier. - Added the intergation with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). -- Comleted a penetration test with a `very good` result. +- Completed a penetration test with a `very good` result. - Added support for multi-line secrets. - ## May 2023 - Released secret scanning capability for the CLI. @@ -26,7 +33,7 @@ The changelog below reflects new product developments and updates on a monthly b - Completed penetration test. - Released new landing page. - Started SOC 2 (Type II) compliance certification preparation. -- Released new deployment options for Fly.io, Digital Ocean and Render. +- Released new deployment options for Fly.io, Digital Ocean and Render. ## April 2023 diff --git a/docs/contributing/developing.mdx b/docs/contributing/developing.mdx index 1c7708c99..bc1bc8dd5 100644 --- a/docs/contributing/developing.mdx +++ b/docs/contributing/developing.mdx @@ -82,4 +82,28 @@ Password: `testInfisical1` ```bash # To stop environment use Control+C (on Mac) CTRL+C (on Win) or docker-compose -f docker-compose.dev.yml down +``` + +## Starting Infisical docs locally + +We use [Mintlify](https://mintlify.com/) for our docs. + +#### Install Mintlify CLI. + +```bash +npm i -g mintlify +``` + +or + +```bash +yarn global add mintlify +``` + +#### Running the docs +Go to `docs` directory and run `mintlify dev`. This will start up the docs on `localhost:3000` + +```bash +# From the root directory +cd docs; mintlify dev; ``` \ No newline at end of file diff --git a/docs/documentation/platform/ip-allowlisting.mdx b/docs/documentation/platform/ip-allowlisting.mdx new file mode 100644 index 000000000..b037d50d2 --- /dev/null +++ b/docs/documentation/platform/ip-allowlisting.mdx @@ -0,0 +1,24 @@ +--- +title: "IP Allowlisting" +description: "Restrict access to your secrets in Infisical using trusted IPs" +--- + +Projects in Infisical can be configured to restrict client access to specific IP addresses or CIDR ranges. This applies to any client using service tokens and +can be useful, for example, for limiting access to traffic coming from corporate networks. + +By default, each project is initialized with the `0.0.0.0/0` entry, representing all possible IPv4 addresses. +For enhanced security, we strongly recommend replacing the default entry with your client IPs to tighten access to your secrets. + + + You must be a project `admin` to manage your project's IP whitelist. + + +![IP whitelist](../../images/project-ip-whitelist.png) + +## Creating a trusted IP entry + +To create a trusted IP entry, head over to the **IP Whitelist** tab in your project. When creating an entry, +you can specify either a specific IP address like `192.0.2.1` or a CIDR range like `2001:db8::/32`; both IPv4 and IPv6 +formats are accepted. + +![IP whitelist add](../../images/project-ip-whitelist-add.png) 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/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 4192e1606..380facd94 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -45,8 +45,8 @@ To add an import, simply click on the `Add import` button and provide the enviro ![secret import change order](../../images/secret-import-add.png) The hierarchy of importing secrets is governed by a "last-one-wins" rule. This means the sequence in which you import matters - the final folder imported will override secrets from any prior folders. -Moreover, any secrets you define directly in your environment will take precedence over secrets from any imported folders. +Additionally, any secrets you define directly in your environment will override any secrets that are imported with the same name. -You can modify this sequence by dragging and rearranging the folders using the `Change Order` drag handle. +You can modify the order of folders to control overrides using the `Change Order` drag handle. ![secret import change order](../../images/secret-import-change-order.png) 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/integrations-do-dashboard.png b/docs/images/integrations-do-dashboard.png new file mode 100644 index 000000000..af9a06d07 Binary files /dev/null and b/docs/images/integrations-do-dashboard.png differ diff --git a/docs/images/integrations-do-enter-token.png b/docs/images/integrations-do-enter-token.png new file mode 100644 index 000000000..48a436278 Binary files /dev/null and b/docs/images/integrations-do-enter-token.png differ diff --git a/docs/images/integrations-do-select-projects.png b/docs/images/integrations-do-select-projects.png new file mode 100644 index 000000000..cb1878f5f Binary files /dev/null and b/docs/images/integrations-do-select-projects.png differ diff --git a/docs/images/integrations-do-success.png b/docs/images/integrations-do-success.png new file mode 100644 index 000000000..3866c4a7d Binary files /dev/null and b/docs/images/integrations-do-success.png differ diff --git a/docs/images/integrations-do-token-modal.png b/docs/images/integrations-do-token-modal.png new file mode 100644 index 000000000..47e8a27fe Binary files /dev/null and b/docs/images/integrations-do-token-modal.png differ diff --git a/docs/images/integrations-terraformcloud-auth.png b/docs/images/integrations-terraformcloud-auth.png new file mode 100644 index 000000000..930fdfafa Binary files /dev/null and b/docs/images/integrations-terraformcloud-auth.png differ diff --git a/docs/images/integrations-terraformcloud-create.png b/docs/images/integrations-terraformcloud-create.png new file mode 100644 index 000000000..7c0ee12ee Binary files /dev/null and b/docs/images/integrations-terraformcloud-create.png differ diff --git a/docs/images/integrations-terraformcloud-dashboard.png b/docs/images/integrations-terraformcloud-dashboard.png new file mode 100644 index 000000000..d73c8c265 Binary files /dev/null and b/docs/images/integrations-terraformcloud-dashboard.png differ diff --git a/docs/images/integrations-terraformcloud-tokens.png b/docs/images/integrations-terraformcloud-tokens.png new file mode 100644 index 000000000..604c70132 Binary files /dev/null and b/docs/images/integrations-terraformcloud-tokens.png differ diff --git a/docs/images/integrations-terraformcloud-workspaceid.png b/docs/images/integrations-terraformcloud-workspaceid.png new file mode 100644 index 000000000..32558566f Binary files /dev/null and b/docs/images/integrations-terraformcloud-workspaceid.png differ diff --git a/docs/images/integrations-terraformcloud-workspaces.png b/docs/images/integrations-terraformcloud-workspaces.png new file mode 100644 index 000000000..08366778e Binary files /dev/null and b/docs/images/integrations-terraformcloud-workspaces.png differ diff --git a/docs/images/integrations-terraformcloud.png b/docs/images/integrations-terraformcloud.png new file mode 100644 index 000000000..02a6ca2df Binary files /dev/null and b/docs/images/integrations-terraformcloud.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png index 89c4c6d66..af2a45125 100644 Binary files a/docs/images/integrations.png and b/docs/images/integrations.png differ diff --git a/docs/images/project-ip-whitelist-add.png b/docs/images/project-ip-whitelist-add.png new file mode 100644 index 000000000..c045daa4c Binary files /dev/null and b/docs/images/project-ip-whitelist-add.png differ diff --git a/docs/images/project-ip-whitelist.png b/docs/images/project-ip-whitelist.png new file mode 100644 index 000000000..d4c449064 Binary files /dev/null and b/docs/images/project-ip-whitelist.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/integrations/cloud/digital-ocean-app-platform.mdx b/docs/integrations/cloud/digital-ocean-app-platform.mdx new file mode 100644 index 000000000..10dd7751d --- /dev/null +++ b/docs/integrations/cloud/digital-ocean-app-platform.mdx @@ -0,0 +1,39 @@ +--- +title: "Digital Ocean App Platform" +description: "How to sync secrets from Infisical to Digital Ocean App Platform" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Get your Digital Ocean Personal Access Tokens + +On Digital Ocean dashboard, navigate to **API > Tokens** and click on "Generate New Token" +![integrations digital ocean dashboard](../../images/integrations-do-dashboard.png) + +Name it **infisical**, choose **No expiry**, and make sure to check **Write (optional)**. Then click on "Generate Token" and copy your API token. +![integrations digital ocean token modal](../../images/integrations-do-token-modal.png) + +## Navigate to your project's integrations tab + +Click on the **Digital Ocean App Platform** tile and enter your API token to grant Infisical access to your Digital Ocean account. +![integrations](../../images/integrations.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. + + +Then enter your Digital Ocean Personal Access Token here. Then click "Connect to Digital Ocean App Platform". +![integrations infisical dashboard digital ocean integration](../../images/integrations-do-enter-token.png) + +## Start integration + +Select which Infisical environment secrets you want to sync to which Digital Ocean App and click "Create Integration". +![integrations digital ocean select projects](../../images/integrations-do-select-projects.png) + +Done! +![integrations digital ocean integration success](../../images/integrations-do-success.png) diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx new file mode 100644 index 000000000..9000fe194 --- /dev/null +++ b/docs/integrations/cloud/terraform-cloud.mdx @@ -0,0 +1,42 @@ +--- +title: "Terraform Cloud" +description: "How to sync secrets from Infisical to Terraform Cloud" +--- + +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 Terraform Cloud API Token and Workspace Id + +Obtain a Terraform Cloud API Token in User Settings > Tokens + +![integrations terraform cloud dashboard](../../images/integrations-terraformcloud-dashboard.png) +![integrations terraform cloud tokens](../../images/integrations-terraformcloud-tokens.png) + +Obtain your Terraform Cloud Workspace Id in Projects & Workspaces > Workspace > ID + +![integrations terraform cloud projects & workspaces](../../images/integrations-terraformcloud-workspaces.png) +![integrations terraform cloud workspace id](../../images/integrations-terraformcloud-workspaceid.png) + +Press on the Terraform Cloud tile and input your Terraform Cloud API Token and Workspace Id to grant Infisical access to your Terraform Cloud account. + +![integrations terraform cloud authorization](../../images/integrations-terraformcloud-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. + + +## Start integration + +Select which Infisical environment secrets and Terraform Cloud variable type you want to sync to which Terraform Cloud workspace/project and press create integration to start syncing secrets to Terraform Cloud. + +![integrations terraform cloud](../../images/integrations-terraformcloud-create.png) +![integrations terraform cloud](../../images/integrations-terraformcloud.png) diff --git a/docs/integrations/frameworks/react.mdx b/docs/integrations/frameworks/react.mdx index 48ad5d74f..ce50be1c8 100644 --- a/docs/integrations/frameworks/react.mdx +++ b/docs/integrations/frameworks/react.mdx @@ -26,3 +26,7 @@ infisical run -- # Example infisical run -- npm run dev ``` + + + React environment variables must be prefixed with `REACT_APP_` to show up within the application + diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 988c0a064..d04b0349f 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -20,6 +20,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Render](/integrations/cloud/render) | Cloud | Available | | [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | | [Railway](/integrations/cloud/railway) | Cloud | Available | +| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | | [Fly.io](/integrations/cloud/flyio) | Cloud | Available | | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 01e011f79..3d26536ba 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -66,12 +66,12 @@ metadata: spec: # The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used hostAPI: https://app.infisical.com/api - resyncInterval: + resyncInterval: 60 authentication: serviceToken: serviceTokenSecretReference: secretName: service-token - secretNamespace: option + secretNamespace: default secretsScope: envSlug: dev secretsPath: "/" diff --git a/docs/mint.json b/docs/mint.json index c0d8f9cbe..c81ff67da 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,8 +118,10 @@ "documentation/platform/pit-recovery", "documentation/platform/secret-versioning", "documentation/platform/audit-logs", + "documentation/platform/token", + "documentation/platform/ip-allowlisting", "documentation/platform/mfa", - "documentation/platform/token" + "documentation/platform/saml" ] }, { @@ -135,7 +137,6 @@ "self-hosting/deployment-options/standalone-infisical", "self-hosting/deployment-options/fly.io", "self-hosting/deployment-options/render", - "self-hosting/deployment-options/laravel-forge", "self-hosting/deployment-options/digital-ocean-marketplace" ] }, @@ -207,6 +208,12 @@ "integrations/cloud/aws-secret-manager" ] }, + { + "group": "Digital Ocean", + "pages": [ + "integrations/cloud/digital-ocean-app-platform" + ] + }, "integrations/cloud/heroku", "integrations/cloud/vercel", "integrations/cloud/netlify", @@ -215,10 +222,12 @@ "integrations/cloud/flyio", "integrations/cloud/laravel-forge", "integrations/cloud/supabase", + "integrations/cloud/terraform-cloud", "integrations/cloud/cloudflare-pages", "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", @@ -324,7 +333,8 @@ "pages": [ "security/overview", "security/data-model", - "security/mechanics" + "security/mechanics", + "security/service-tokens" ] }, { diff --git a/docs/security/service-tokens.mdx b/docs/security/service-tokens.mdx new file mode 100644 index 000000000..b70f527eb --- /dev/null +++ b/docs/security/service-tokens.mdx @@ -0,0 +1,22 @@ +--- +title: "Service Tokens" +description: "Understanding service tokens and their best practices" +--- + +## Background + +Many clients use service tokens to authenticate and read/write secrets from/to Infisical. + +Each service token consist of two parts used for authentication and decryption, separated by `.`. Consider the token `st.abc.def.ghi`. Here, `st.abc.def` can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer st.abc.def`, and retrieve (encrypted) secrets as well as a project key back. Meanwhile, `ghi`, a hex-string, can be used to decrypt the project key used to decrypt the secrets. + +Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. + +## Recommendations + +1. Issuance: When creating a new service token, it’s important to consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when setting its scope and expiration date. For example, if the client using the token only requires access to a staging environment, then you should scope the token to that environment only; you can further scope tokens to path(s) within environment(s) if you happen to use [path-based secret storage](/documentation/platform/folder). Likewise, if the client does not intend to access secrets indefinitely, then you may consider setting a finite lifetime for the token such as 6 months or 1 year from now. Finally, you should consider carefully whether or not your client requires the ability to read and/or write secrets from/to Infisical. + +2. Network access: We recommend configuring the IP whitelist settings of each project to allow either single IP addresses or CIDR-notated range of addresses to read/write secrets to Infisical. With this feature, you can specify the IP range of your client servers to restrict access to your project in Infisical. + +3. Storage: Since service tokens grant access to your secrets, we recommend storing service tokens securely across your development cycle whether it be in a `.env` file in local development or as an environment variable of your deployment platform. + +4. Rotation: We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, all of which use AES-256-GCM encryption, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by NIST publication 800-38D. Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. 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 c2c9e5c9a..f05c2ade0 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -19,10 +19,13 @@ const integrationSlugNameMapping: Mapping = { travisci: "TravisCI", supabase: "Supabase", checkly: "Checkly", + 'terraform-cloud': 'Terraform Cloud', "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", + "digital-ocean-app-platform": "Digital Ocean App Platform", bitbucket: "BitBucket", + "cloud-66": "Cloud 66", northflank: "Northflank" }; 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/public/images/integrations/Terraform Cloud.png b/frontend/public/images/integrations/Terraform Cloud.png new file mode 100644 index 000000000..c0000e98c Binary files /dev/null and b/frontend/public/images/integrations/Terraform Cloud.png differ diff --git a/frontend/src/components/basic/dialog/AddProjectMemberDialog.tsx b/frontend/src/components/basic/dialog/AddProjectMemberDialog.tsx index 87004e0e8..e8049066b 100644 --- a/frontend/src/components/basic/dialog/AddProjectMemberDialog.tsx +++ b/frontend/src/components/basic/dialog/AddProjectMemberDialog.tsx @@ -127,7 +127,7 @@ const AddProjectMemberDialog = ({ ) : ( - -
-
- or -
-
- {/*
+
-
*/} +
+
+ +
+ +
+ ); +} + +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/integrations/digital-ocean-app-platform/authorize.tsx b/frontend/src/pages/integrations/digital-ocean-app-platform/authorize.tsx new file mode 100644 index 000000000..9c3912b2e --- /dev/null +++ b/frontend/src/pages/integrations/digital-ocean-app-platform/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 DigitalOceanAppPlatformCreateIntegrationPage() { + 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("API Key cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "digital-ocean-app-platform", + accessId: null, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/digital-ocean-app-platform/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Digital Ocean App Platform Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +DigitalOceanAppPlatformCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/digital-ocean-app-platform/create.tsx b/frontend/src/pages/integrations/digital-ocean-app-platform/create.tsx new file mode 100644 index 000000000..925729c94 --- /dev/null +++ b/frontend/src/pages/integrations/digital-ocean-app-platform/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 DigitalOceanAppPlatformCreateIntegrationPage() { + 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 ? ( +
+ + Digital Ocean App Platform Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +DigitalOceanAppPlatformCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/northflank/create.tsx b/frontend/src/pages/integrations/northflank/create.tsx index 7ffd7151a..adead79fe 100644 --- a/frontend/src/pages/integrations/northflank/create.tsx +++ b/frontend/src/pages/integrations/northflank/create.tsx @@ -57,7 +57,7 @@ export default function NorthflankCreateIntegrationPage() { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { const selectedApp = integrationAuthApps?.filter((integrationAuthApp) => integrationAuthApp.name === targetApp); - if (selectedApp.length > 0) { + if (selectedApp.length > 0 && selectedApp[0].secretGroups) { setSecretGroupList(selectedApp[0].secretGroups); setTargetSecretGroup(selectedApp[0]?.secretGroups[0]); } else { @@ -172,7 +172,7 @@ export default function NorthflankCreateIntegrationPage() { )) ) : ( - + No secret groups found )} @@ -183,7 +183,7 @@ export default function NorthflankCreateIntegrationPage() { color="mineshaft" className="mt-4" isLoading={isLoading} - isDisabled={integrationAuthApps.length === 0 && secretGroupList === 0} + isDisabled={secretGroupList === 0} > Create Integration diff --git a/frontend/src/pages/integrations/terraform-cloud/authorize.tsx b/frontend/src/pages/integrations/terraform-cloud/authorize.tsx new file mode 100644 index 000000000..c569bdcca --- /dev/null +++ b/frontend/src/pages/integrations/terraform-cloud/authorize.tsx @@ -0,0 +1,80 @@ +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 TerraformCloudCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [workspacesId, setWorkSpacesId] = useState(""); + const [workspacesIdErrorText, setWorkspacesIdErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + setWorkspacesIdErrorText(""); + + if (apiKey.length === 0) { + setApiKeyErrorText("API Token cannot be blank"); + return; + } + + if (workspacesId.length === 0) { + setWorkspacesIdErrorText("Workspace Id cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "terraform-cloud", + accessId: workspacesId, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/terraform-cloud/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Terraform Cloud Integration + + setApiKey(e.target.value)} /> + + + setWorkSpacesId(e.target.value)} /> + + + +
+ ); +} + +TerraformCloudCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/terraform-cloud/create.tsx b/frontend/src/pages/integrations/terraform-cloud/create.tsx new file mode 100644 index 000000000..47c33948c --- /dev/null +++ b/frontend/src/pages/integrations/terraform-cloud/create.tsx @@ -0,0 +1,189 @@ +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"; + +const variableTypes = [ + { name: "env" }, + { name: "terraform" } +]; + +export default function TerraformCloudCreateIntegrationPage() { + 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 [variableType, setVariableType] = useState(""); + const [variableTypeErrorText, setVariableTypeErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + setVariableType(variableTypes[0].name); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setVariableTypeErrorText(""); + if (variableType.length === 0 ) { + setVariableTypeErrorText("Variable Type cannot be blank!") + 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: variableType, + 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 ? ( +
+ + Terraform Cloud Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + + + + +
+ ) : ( +
+ ); +} + +TerraformCloudCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 45b9a1743..693af4d6f 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Head from "next/head"; -import Link from "next/link"; import { useRouter } from "next/router"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; @@ -325,9 +324,12 @@ export default function Organization() { {orgWorkspaces.filter(ws => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())).map(workspace =>
{workspace.name}
{(workspace.environments?.length || 0)} environments
- -
Explore
- +
)}
{orgWorkspaces.length === 0 && ( diff --git a/frontend/src/pages/project/[id]/allowlist/index.tsx b/frontend/src/pages/project/[id]/allowlist/index.tsx new file mode 100644 index 000000000..9014cf0e9 --- /dev/null +++ b/frontend/src/pages/project/[id]/allowlist/index.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IPAllowlistPage } from "@app/views/Project/IPAllowListPage"; + +const ProjectAllowlist = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +} + +export default ProjectAllowlist; + +ProjectAllowlist.requireAuth = true; \ No newline at end of file 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() {
-
-
- setPassword(e.target.value)} - type="password" - placeholder="Enter your password..." - isRequired - autoComplete="current-password" - id="current-password" - className="h-12 select:-webkit-autofill:focus" - /> -
+ {loginEmailChosen && <> +
+
+
+
+ setEmail(e.target.value)} + type="email" + placeholder="Enter your email..." + isRequired + autoComplete="username" + className="h-12" + /> +
+
+ setPassword(e.target.value)} + type="password" + placeholder="Enter your password..." + isRequired + autoComplete="current-password" + id="current-password" + className="h-12 select:-webkit-autofill:focus" + />
- {!isLoading && loginError && }
+ > Continue with Email
-
-
- or -
-
-
+ {!isLoading && loginError && } +
+
+
} + {!loginEmailChosen &&
-
-
+ setLoginEmailChosen(true); + }} + size="sm" + isFullWidth + className='h-12' + colorSchema="primary" + variant="outline_bg" + > Continue with Email +
} +
diff --git a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx index 3eebba058..fe4a0fd01 100644 --- a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx +++ b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx @@ -36,6 +36,7 @@ type Props = { email: string; password: string; providerAuthToken?: string; + callbackPort?: string | null; } interface VerifyMfaTokenError { @@ -53,7 +54,8 @@ interface VerifyMfaTokenError { export const MFAStep = ({ email, password, - providerAuthToken + providerAuthToken, + callbackPort }: Props) => { const { createNotification } = useNotificationContext(); const router = useRouter(); @@ -77,9 +79,7 @@ export const MFAStep = ({ } setIsLoading(true); - const queryParams = new URLSearchParams(window.location.search) - if (queryParams && queryParams.get("callback_port")){ - const callbackPort = queryParams.get("callback_port") + if (callbackPort){ // attemptCliLogin const isCliLoginSuccessful = await attemptCliLoginMfa({ diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index 07a0b1ba3..6076e76bb 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -2,14 +2,17 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router" +import axios from "axios" import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; import { Button, Input } from "@app/components/v2"; import getOrganizations from "@app/pages/api/organization/getOrgs"; type Props = { providerAuthToken: string; + callbackPort?: string; email: string; password: string; setPassword: (password: string) => void; @@ -18,6 +21,7 @@ type Props = { export const PasswordStep = ({ providerAuthToken, + callbackPort, email, password, setPassword, @@ -31,34 +35,64 @@ export const PasswordStep = ({ const handleLogin = async () => { try { setIsLoading(true); - const loginAttempt = await attemptLogin({ - email, - password, - providerAuthToken, - }); + + if (callbackPort) { + // attemptCliLogin + const isCliLoginSuccessful = await attemptCliLogin({ + email, + password, + providerAuthToken + }) - if (loginAttempt && loginAttempt.success) { - // case: login was successful + if (isCliLoginSuccessful && isCliLoginSuccessful.success) { - if (loginAttempt.mfaEnabled) { - // TODO: deal with MFA - // case: login requires MFA step - setIsLoading(false); - setStep(2); - return; + if (isCliLoginSuccessful.mfaEnabled) { + // case: login requires MFA step + setStep(2); + setIsLoading(false); + return; + } + // case: login was successful + const cliUrl = `http://localhost:${callbackPort}` + + // send request to server endpoint + const instance = axios.create() + await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse }) + + // cli page + router.push("/cli-redirect"); + + // on success, router.push to cli Login Successful page } - - // case: login does not require MFA step - const userOrgs = await getOrganizations(); - const userOrg = userOrgs[0]._id; - setIsLoading(false); - createNotification({ - text: "Successfully logged in", - type: "success" + } else { + const loginAttempt = await attemptLogin({ + email, + password, + providerAuthToken, }); - router.push(`/org/${userOrg?._id}/overview`); - } + if (loginAttempt && loginAttempt.success) { + // case: login was successful + + if (loginAttempt.mfaEnabled) { + // TODO: deal with MFA + // case: login requires MFA step + setIsLoading(false); + setStep(2); + return; + } + + // case: login does not require MFA step + const userOrgs = await getOrganizations(); + const userOrg = userOrgs[0]._id; + setIsLoading(false); + createNotification({ + text: "Successfully logged in", + type: "success" + }); + router.push(`/org/${userOrg?._id}/overview`); + } + } } catch (err) { setIsLoading(false); createNotification({ diff --git a/frontend/src/views/Login/components/SAMLSSOStep/SAMLSSOStep.tsx b/frontend/src/views/Login/components/SAMLSSOStep/SAMLSSOStep.tsx index 77e7e6cb4..8ff363d36 100644 --- a/frontend/src/views/Login/components/SAMLSSOStep/SAMLSSOStep.tsx +++ b/frontend/src/views/Login/components/SAMLSSOStep/SAMLSSOStep.tsx @@ -13,6 +13,8 @@ export const SAMLSSOStep = ({ const [ssoIdentifier, setSSOIdentifier] = useState(""); const { t } = useTranslation(); + const queryParams = new URLSearchParams(window.location.search); + return (

@@ -37,7 +39,8 @@ export const SAMLSSOStep = ({ colorSchema="primary" variant="outline_bg" onClick={() => { - window.open(`/api/v1/sso/redirect/saml2/${ssoIdentifier}`); + const callbackPort = queryParams.get("callback_port"); + window.open(`/api/v1/sso/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`); window.close(); }} isFullWidth diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index a995661c9..3a39560ca 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -6,6 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, DeleteActionModal, @@ -26,9 +27,11 @@ import { Th, THead, Tr, - UpgradePlanModal} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; + UpgradePlanModal +} from "@app/components/v2"; +import { useOrganization , useWorkspace } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; +import { useGetSSOConfig } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { OrgUser, Workspace } from "@app/hooks/api/types"; @@ -69,6 +72,9 @@ export const OrgMembersTable = ({ setCompleteInviteLink }: Props) => { const router = useRouter(); + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? ""); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const {data: serverDetails } = useFetchServerStatus() const { workspaces } = useWorkspace(); @@ -79,7 +85,7 @@ export const OrgMembersTable = ({ "upgradePlan", "setUpEmail" ] as const); - + useEffect(() => { if (router.query.action === "invite") { handlePopUpOpen("addMember"); @@ -152,6 +158,15 @@ export const OrgMembersTable = ({ + )} + ( + + + + )} + /> +

+ + +
+ + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx new file mode 100644 index 000000000..729ab549e --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx @@ -0,0 +1,105 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + UpgradePlanModal +} from "@app/components/v2"; +import { useSubscription,useWorkspace } from "@app/context"; +import { + useDeleteTrustedIp +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { IPAllowlistModal } from "./IPAllowlistModal"; +import { IPAllowlistTable } from "./IPAllowlistTable"; + +export const IPAllowlistSection = () => { + const { createNotification } = useNotificationContext(); + const { mutateAsync } = useDeleteTrustedIp(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "trustedIp", + "deleteTrustedIp", + "upgradePlan" + ] as const); + + const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { + try { + + if (!currentWorkspace?._id) return; + + await mutateAsync({ + workspaceId: currentWorkspace._id, + trustedIpId + }); + + createNotification({ + text: "Successfully deleted IP access range", + type: "success" + }); + + handlePopUpClose("deleteTrustedIp"); + } catch (err) { + console.log(err); + createNotification({ + text: "Failed to delete IP access range", + type: "error" + }); + } + } + + return ( +
+
+

+ IP Allowlist +

+ +
+ + + handlePopUpToggle("deleteTrustedIp", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx new file mode 100644 index 000000000..d160f15db --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx @@ -0,0 +1,162 @@ +import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { useSubscription, useWorkspace } from "@app/context"; +import { + useGetTrustedIps +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["upgradePlan"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, + data?: { + trustedIpId: string; + ipAddress?: string; + comment?: string; + isActive?: boolean; + prefix?: number; + }, + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; +}; + +export const IPAllowlistTable = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); + + const formatType = (type: string, prefix?: number) => { + return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`; + } + + return ( +
+ + + + + + + + {/* */} + + + + {!isLoading && data && data?.length > 0 && data + .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + .map(({ + _id, + ipAddress, + comment, + type, + prefix, + isActive + }) => { + return ( + + + + + {/* */} + + + ); + })} + {isLoading && } + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
IP Address / RangeFormatCommentStatus +
+ {`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`} + + {formatType(type, prefix)} + + {comment} + +
+ +

Active

+
+
+ { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("trustedIp", { + trustedIpId: _id, + ipAddress, + comment, + prefix, + isActive + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("deleteTrustedIp", { + trustedIpId: _id + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/IPAllowListPage/components/index.tsx b/frontend/src/views/Project/IPAllowListPage/components/index.tsx new file mode 100644 index 000000000..d4146cb4d --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/components/index.tsx @@ -0,0 +1 @@ +export { IPAllowlistSection } from "./IPAllowlistSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/IPAllowListPage/index.tsx b/frontend/src/views/Project/IPAllowListPage/index.tsx new file mode 100644 index 000000000..e7a407184 --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/index.tsx @@ -0,0 +1 @@ +export { IPAllowlistPage } from "./IPAllowlistPage"; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx index 4eba19a50..743106998 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx @@ -11,7 +11,8 @@ import { Td, Th, THead, - Tr} from "@app/components/v2"; + Tr +} from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useDeleteOrgPmtMethod, diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/InvoicesTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/InvoicesTable.tsx index 9050f9d5f..612052a36 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/InvoicesTable.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/InvoicesTable.tsx @@ -11,7 +11,8 @@ import { Td, Th, THead, - Tr} from "@app/components/v2"; + Tr +} from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useGetOrgInvoices diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 7ca84318d..a3eec451d 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -102,7 +102,7 @@ export const AuthMethodSection = () => { defaultValue={field.value} {...field} onValueChange={(e) => onChange(e)} - className="w-full" + className="w-full bg-mineshaft-800 border border-mineshaft-600" > {authMethods.map((authMethod) => { return ( diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx index c433801e1..4e901989d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx @@ -18,7 +18,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "deleteEnv", "upgradePlan"]>, + popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>, { name, slug diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 4c0f50f44..fed039467 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -69,13 +69,13 @@ export const UserInfoSSOStep = ({ const [nameError, setNameError] = useState(false); const [organizationName, setOrganizationName] = useState(""); const [organizationNameError, setOrganizationNameError] = useState(false); + const [attributionSource, setAttributionSource] = useState(""); const [errors, setErrors] = useState({}); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); useEffect(() => { - console.log("providerOrganizationName: ", providerOrganizationName); - if (providerOrganizationName) { + if (providerOrganizationName !== undefined) { setOrganizationName(providerOrganizationName); } }, []); @@ -112,10 +112,6 @@ export const UserInfoSSOStep = ({ const privateKey = encodeBase64(secretKeyUint8Array); const publicKey = encodeBase64(publicKeyUint8Array); localStorage.setItem("PRIVATE_KEY", privateKey); - - console.log("make"); - console.log("email: ", email); - console.log("password: ", password); client.init( { @@ -175,7 +171,8 @@ export const UserInfoSSOStep = ({ providerAuthToken, salt: result.salt, verifier: result.verifier, - organizationName + organizationName, + attributionSource }); // unset signup JWT token and set JWT token @@ -213,7 +210,7 @@ export const UserInfoSSOStep = ({ setIsLoading(false); } }; - + return (

@@ -232,18 +229,31 @@ export const UserInfoSSOStep = ({ /> {nameError &&

Please, specify your name

}
-
-

Organization Name

- setOrganizationName(e.target.value)} - isRequired - className="h-12" - disabled - /> - {organizationNameError &&

Please, specify your organization name

} -
+ {providerOrganizationName === undefined && ( +
+

Organization Name

+ setOrganizationName(e.target.value)} + isRequired + className="h-12" + disabled + /> + {organizationNameError &&

Please, specify your organization name

} +
+ )} + {providerOrganizationName === undefined && ( +
+

Where did you hear about us? (optional)

+ setAttributionSource(e.target.value)} + value={attributionSource} + className="h-12" + /> +
+ )}