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/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..64b6980f8 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -8,11 +8,14 @@ import { 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 +591,132 @@ 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 trustedIp = await TrustedIP.findOneAndUpdate( + { + _id: new Types.ObjectId(trustedIpId), + workspace: new Types.ObjectId(workspaceId), + }, + { + ipAddress, + type, + prefix, + comment + }, + { + 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..236a758ac --- /dev/null +++ b/backend/src/ee/models/trustedIp.ts @@ -0,0 +1,55 @@ +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, + required: true + } + }, + { + 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/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/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/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/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 cea90a6ca..6d3b4911d 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -56,7 +56,8 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.getSecretByNameRaw ); @@ -84,7 +85,8 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.createSecretRaw ); @@ -112,7 +114,8 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.updateSecretByNameRaw ); @@ -139,7 +142,8 @@ router.delete( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true + requireE2EEOff: true, + checkIPAllowlist: true }), secretsController.deleteSecretByNameRaw ); @@ -164,7 +168,8 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.getSecrets ); @@ -199,7 +204,8 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.createSecret ); @@ -225,7 +231,8 @@ router.get( locationWorkspaceId: "query", locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true + requireBlindIndicesEnabled: true, + checkIPAllowlist: true }), secretsController.getSecretByName ); @@ -255,7 +262,8 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false + requireE2EEOff: false, + checkIPAllowlist: true }), secretsController.updateSecretByName ); @@ -282,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/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 1405af000..fdc766316 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, @@ -549,3 +555,31 @@ 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) return; + + const trustedIpsToInsert = workspaceIdsToAddTrustedIp.map((workspaceId) => { + return new TrustedIP({ + workspace: new Types.ObjectId(workspaceId), + ipAddress: "0.0.0.0", + type: IPType.IPV4, + prefix: 0, + isActive: true, + comment: "", + }).save(); + }); + + await TrustedIP.insertMany(trustedIpsToInsert); +} 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/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/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/mint.json b/docs/mint.json index e34531a32..b20a9f9fe 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -119,6 +119,7 @@ "documentation/platform/secret-versioning", "documentation/platform/audit-logs", "documentation/platform/token", + "documentation/platform/ip-allowlisting", "documentation/platform/mfa", "documentation/platform/saml" ] diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 59840d9b3..672e51052 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -14,6 +14,7 @@ export * from "./serviceTokens"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; +export * from "./trustedIps"; export * from "./users"; export * from "./webhooks"; export * from "./workspace"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index e54b3923a..433092db9 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -6,6 +6,7 @@ export type SubscriptionPlan = { customAlerts: boolean; customRateLimits: boolean; pitRecovery: boolean; + ipAllowlisting: boolean; rbac: boolean; secretVersioning: boolean; slug: string; diff --git a/frontend/src/hooks/api/trustedIps/index.ts b/frontend/src/hooks/api/trustedIps/index.ts new file mode 100644 index 000000000..eb86f8e40 --- /dev/null +++ b/frontend/src/hooks/api/trustedIps/index.ts @@ -0,0 +1,5 @@ +export { + useAddTrustedIp, + useDeleteTrustedIp, + useGetTrustedIps, + useUpdateTrustedIp} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/trustedIps/queries.ts b/frontend/src/hooks/api/trustedIps/queries.ts new file mode 100644 index 000000000..f239bb0ca --- /dev/null +++ b/frontend/src/hooks/api/trustedIps/queries.ts @@ -0,0 +1,108 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TrustedIp +} from "./types"; + +const trustedIps = { + getTrustedIps: (workspaceId: string) => [{ workspaceId }, "trusted-ips"] as const +} + +export const useGetTrustedIps = (workspaceId: string) => { + return useQuery({ + queryKey: trustedIps.getTrustedIps(workspaceId), + queryFn: async () => { + const { data } = await apiRequest.get<{ trustedIps: TrustedIp[] }>(`/api/v1/workspace/${workspaceId}/trusted-ips`); + + return data.trustedIps; + } + }); +} + +export const useAddTrustedIp = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + workspaceId, + ipAddress, + comment, + isActive + }: { + workspaceId: string; + ipAddress: string; + comment?: string; + isActive: boolean; + }) => { + const { data } = await apiRequest.post( + `/api/v1/workspace/${workspaceId}/trusted-ips`, + { + ipAddress, + ...(comment ? { comment } : {}), + isActive + } + ); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(trustedIps.getTrustedIps(dto.workspaceId)); + } + }); +}; + +export const useUpdateTrustedIp = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + workspaceId, + trustedIpId, + ipAddress, + comment, + isActive + }: { + workspaceId: string; + trustedIpId: string; + ipAddress: string; + comment?: string; + isActive: boolean; + }) => { + const { data } = await apiRequest.patch( + `/api/v1/workspace/${workspaceId}/trusted-ips/${trustedIpId}`, + { + ipAddress, + ...(comment ? { comment } : {}), + isActive + } + ); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(trustedIps.getTrustedIps(dto.workspaceId)); + } + }); +}; + +export const useDeleteTrustedIp = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + workspaceId, + trustedIpId, + }: { + workspaceId: string; + trustedIpId: string; + }) => { + const { data } = await apiRequest.delete( + `/api/v1/workspace/${workspaceId}/trusted-ips/${trustedIpId}` + ); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(trustedIps.getTrustedIps(dto.workspaceId)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/trustedIps/types.ts b/frontend/src/hooks/api/trustedIps/types.ts new file mode 100644 index 000000000..e8985b2de --- /dev/null +++ b/frontend/src/hooks/api/trustedIps/types.ts @@ -0,0 +1,9 @@ +export type TrustedIp = { + _id: string; + workspace: string; + ipAddress: string; + type: "ipv4" | "ipv6"; + isActive: boolean; + comment: string; + prefix?: number; +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 0c00810e5..a209367e2 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -6,6 +6,7 @@ export { useDeleteAPIKey, useDeleteOrgMembership, useGetMyAPIKeys, + useGetMyIp, useGetMySessions, useGetOrgUsers, useGetUser, diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 19504217e..1cc485aa4 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -25,6 +25,7 @@ const userKeys = { getUser: ["user"] as const, userAction: ["user-action"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], + myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, mySessions: ["sessions"] as const }; @@ -206,6 +207,19 @@ export const useLogoutUser = () => } }); +export const useGetMyIp = () => { + return useQuery({ + queryKey: userKeys.myIp, + queryFn: async () => { + const { data } = await apiRequest.get<{ ip: string; }>( + "/api/v1/users/me/ip" + ); + return data.ip; + }, + enabled: true + }); +} + export const useGetMyAPIKeys = () => { return useQuery({ queryKey: userKeys.myAPIKeys, diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 6a41e820a..f25c2d05a 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -404,6 +404,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + IP Allowlist + + + { + 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/views/Project/IPAllowListPage/IPAllowlistPage.tsx b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx new file mode 100644 index 000000000..9940d6bae --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx @@ -0,0 +1,15 @@ +import { IPAllowlistSection } from "./components"; + +export const IPAllowlistPage = () => { + return ( +
+
+
+

IP Allowlist

+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistModal.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistModal.tsx new file mode 100644 index 000000000..07de4425e --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistModal.tsx @@ -0,0 +1,187 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useAddTrustedIp, + useGetMyIp, + useUpdateTrustedIp +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + ipAddress: yup.string().required("IP address is required"), + comment: yup.string() +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["trustedIp"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void; +}; + +export const IPAllowlistModal = ({ + popUp, + handlePopUpClose, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { data, isLoading } = useGetMyIp(); + + const { currentWorkspace } = useWorkspace(); + const addTrustedIp = useAddTrustedIp(); + const updateTrustedIp = useUpdateTrustedIp(); + + const { + control, + setValue, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + useEffect(() => { + if (popUp?.trustedIp?.data) { + reset(popUp?.trustedIp?.data as { + ipAddress: string; + comment: string; + }); + } else { + reset({ + ipAddress: "", + comment: "" + }); + } + + }, [popUp?.trustedIp?.data]); + + const onIPAllowlistModalSubmit = async ({ + ipAddress, + comment + }: FormData) => { + try { + if (!currentWorkspace?._id) return; + + if (popUp?.trustedIp?.data) { + await updateTrustedIp.mutateAsync({ + workspaceId: currentWorkspace._id, + trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId, + ipAddress, + comment, + isActive: true + }); + } else { + await addTrustedIp.mutateAsync({ + workspaceId: currentWorkspace._id, + ipAddress, + comment, + isActive: true + }); + } + + createNotification({ + text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`, + type: "success" + }); + + reset(); + handlePopUpClose("trustedIp"); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("trustedIp", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + {!isLoading && data && ( + + )} + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +} \ 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..cd93d7c1b --- /dev/null +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx @@ -0,0 +1,160 @@ +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; + }, + ) => 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, + 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/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