diff --git a/messages/en-US.json b/messages/en-US.json index 576585891..af0c9546e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2543,6 +2543,7 @@ "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", "exitNode": "Exit Node", "country": "Country", + "countryIsNot": "Country Is Not", "rulesMatchCountry": "Currently based on source IP", "region": "Region", "selectRegion": "Select region", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 5375e4fc2..b207b423b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -999,7 +999,17 @@ export const resourceRules = pgTable("resourceRules", { enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), action: varchar("action").notNull(), // ACCEPT, DROP, PASS - match: varchar("match").notNull(), // CIDR, PATH, IP + match: varchar("match") + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() + .notNull(), // CIDR, PATH, IP value: varchar("value").notNull() }); @@ -1014,7 +1024,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { priority: integer("priority").notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), match: varchar("match") - .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() .notNull(), value: varchar("value").notNull() }); @@ -1594,3 +1612,4 @@ export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; +export type ResourcePolicyRule = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5f0c2f9a7..4ec4916b5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1224,7 +1224,17 @@ export const resourceRules = sqliteTable("resourceRules", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP, PASS - match: text("match").notNull(), // CIDR, PATH, IP + match: text("match") + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() + .notNull(), // CIDR, PATH, IP value: text("value").notNull() }); @@ -1271,7 +1281,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { priority: integer("priority").notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), match: text("match") - .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() .notNull(), value: text("value").notNull() }); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 92d6239fa..df3b3799e 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -1,56 +1,54 @@ +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { hashPassword } from "@server/auth/password"; +import { generateId } from "@server/auth/sessions/app"; +import { build } from "@server/build"; import { - domains, domainNamespaces, + domains, orgDomains, Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourcePassword, resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, + resourcePolicyRules, + resourcePolicyWhiteList, resourceRules, + resources, resourceWhitelist, roleActions, + rolePolicies, roleResources, roles, + sites, Target, TargetHealthCheck, targetHealthCheck, + targets, Transaction, userOrgs, + userPolicies, userResources, users, - resourcePolicies, - resourcePolicyPassword, - resourcePolicyPincode, - resourcePolicyHeaderAuth, - resourcePolicyRules, - resourcePolicyWhiteList, - rolePolicies, - userPolicies + type ResourceRule } from "@server/db"; -import { resources, targets, sites } from "@server/db"; -import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; -import { - Config, - ConfigSchema, - isTargetsOnlyResource, - TargetData -} from "./types"; -import logger from "@server/logger"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { pickPort } from "@server/routers/target/helpers"; -import { resourcePassword } from "@server/db"; import { getUniqueResourcePolicyName } from "@server/db/names"; -import { hashPassword } from "@server/auth/password"; -import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; -import { tierMatrix } from "../billing/tierMatrix"; -import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; -import { build } from "@server/build"; -import { encrypt } from "@server/lib/crypto"; -import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; +import { encrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; +import { pickPort } from "@server/routers/target/helpers"; +import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm"; +import { tierMatrix } from "../billing/tierMatrix"; +import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { Config, isTargetsOnlyResource, TargetData } from "./types"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import next from "next"; @@ -909,7 +907,7 @@ export async function updatePublicResources( .update(resourceRules) .set({ action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), + match: rule.match.toUpperCase() as ResourceRule["match"], value: getRuleValue( rule.match.toUpperCase(), rule.value @@ -928,7 +926,7 @@ export async function updatePublicResources( await trx.insert(resourceRules).values({ resourceId: existingResource.resourceId, action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), + match: rule.match.toUpperCase() as ResourceRule["match"], value: getRuleValue( rule.match.toUpperCase(), rule.value @@ -1011,7 +1009,7 @@ export async function updatePublicResources( } else { // create a brand new resource - if (build == "saas") { + if (build === "saas") { const usage = await usageService.getUsage( orgId, LimitId.PUBLIC_RESOURCES @@ -1292,7 +1290,7 @@ export async function updatePublicResources( await trx.insert(resourceRules).values({ resourceId: newResource.resourceId, action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), + match: rule.match.toUpperCase() as ResourceRule["match"], value: getRuleValue( rule.match.toUpperCase(), rule.value @@ -1355,7 +1353,7 @@ function getRuleAction(input: string) { function getRuleValue(match: string, value: string) { // if the match is a country, uppercase the value - if (match == "COUNTRY") { + if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") { return value.toUpperCase(); } return value; diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index f8d8d1269..aa5f4bc9b 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" { function getRuleMatch( input: string -): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" { +): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" { return input.toUpperCase() as | "CIDR" | "IP" | "PATH" | "COUNTRY" + | "COUNTRY_IS_NOT" | "ASN" | "REGION"; } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index ec19bd852..ff0c5fb3d 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [ "IP", "PATH", "COUNTRY", + "COUNTRY_IS_NOT", "ASN", "REGION" ] as const; @@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError( case "REGION": return isValidRegionId(value) ? null : "Invalid region ID provided"; case "COUNTRY": + case "COUNTRY_IS_NOT": return COUNTRIES.some((country) => country.code === value) ? null : "Invalid country code provided"; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 8beea35f0..124db587a 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -1695,6 +1695,7 @@ hybridRouter.get( ) { for (const rule of rules) { if (rule.match == "COUNTRY") { + // @ts-expect-error this is for backward compatibility rule.match = "GEOIP"; } } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index d3076ec4d..987d7e061 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1054,7 +1054,10 @@ async function checkRules( isPathAllowed(rule.value, path) ) { return rule.action as any; - } else if (clientIp && rule.match == "COUNTRY") { + } else if ( + clientIp && + (rule.match === "COUNTRY" || rule.match === "COUNTRY_IS_NOT") + ) { // COUNTRY=ALL should not affect local/private/CGNAT addresses. if ( rule.value.toUpperCase() === "ALL" && @@ -1063,7 +1066,10 @@ async function checkRules( continue; } - if (await isIpInGeoIP(ipCC, rule.value)) { + const inCountry = await isIpInGeoIP(ipCC, rule.value); + const matched = rule.match === "COUNTRY" ? inCountry : !inCountry; + + if (matched) { return rule.action as any; } } else if (clientIp && rule.match == "ASN") { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 84afb38b6..c724d00d2 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -27,6 +27,7 @@ const resourceRuleMatchSchema = z.enum([ "IP", "PATH", "COUNTRY", + "COUNTRY_IS_NOT", "ASN", "REGION" ]); @@ -144,6 +145,7 @@ export async function updateResourceRule( | "IP" | "PATH" | "COUNTRY" + | "COUNTRY_IS_NOT" | "ASN" | "REGION"; diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index b8445a44c..2ff2e1915 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -75,6 +75,7 @@ import { type PolicyAccessRule } from "./policy-access-rule-utils"; import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; +import type { readonly } from "zod"; export type PolicyAccessRulesTableProps = { rules: PolicyAccessRule[]; @@ -106,7 +107,7 @@ function getColumnClassName(columnId: string) { return "w-42 max-w-42"; } if (columnId === "match") { - return "w-36 max-w-36"; + return "w-42 max-w-42"; } return ""; } @@ -230,6 +231,7 @@ export function PolicyAccessRulesTable({ IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), + COUNTRY_IS_NOT: t("countryIsNot"), ASN: "ASN", REGION: t("region") }), @@ -442,13 +444,15 @@ export function PolicyAccessRulesTable({ | "IP" | "PATH" | "COUNTRY" + | "COUNTRY_IS_NOT" | "ASN" | "REGION" ) => updateRule(row.original.ruleId, { match: value, value: - value === "COUNTRY" + value === "COUNTRY" || + value === "COUNTRY_IS_NOT" ? "US" : value === "ASN" ? "AS15169" @@ -458,7 +462,7 @@ export function PolicyAccessRulesTable({ }) } > - + @@ -470,9 +474,14 @@ export function PolicyAccessRulesTable({ {RuleMatch.CIDR} {isMaxmindAvailable && ( - - {RuleMatch.COUNTRY} - + <> + + {RuleMatch.COUNTRY} + + + {RuleMatch.COUNTRY_IS_NOT} + + )} {includeRegionMatch && isMaxmindAvailable && ( @@ -494,14 +503,16 @@ export function PolicyAccessRulesTable({ cell: ({ row }) => { let selectedCountry: (typeof COUNTRIES)[number] | undefined; if ( - row.original.match === "COUNTRY" && + (row.original.match === "COUNTRY" || + row.original.match === "COUNTRY_IS_NOT") && row.original.value ) { selectedCountry = COUNTRIES.find( (c) => c.code === row.original.value ); } - return row.original.match === "COUNTRY" ? ( + return row.original.match === "COUNTRY" || + row.original.match === "COUNTRY_IS_NOT" ? (