Merge pull request #3342 from Fredkiss3/feat/geoip-country-is-not-rule

feat: add country `is not` rule in resource policies
This commit is contained in:
Milo Schwartz
2026-07-07 21:18:43 -04:00
committed by GitHub
11 changed files with 110 additions and 49 deletions
+1
View File
@@ -2543,6 +2543,7 @@
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
"exitNode": "Exit Node", "exitNode": "Exit Node",
"country": "Country", "country": "Country",
"countryIsNot": "Country Is Not",
"rulesMatchCountry": "Currently based on source IP", "rulesMatchCountry": "Currently based on source IP",
"region": "Region", "region": "Region",
"selectRegion": "Select region", "selectRegion": "Select region",
+21 -2
View File
@@ -999,7 +999,17 @@ export const resourceRules = pgTable("resourceRules", {
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP, PASS 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() value: varchar("value").notNull()
}); });
@@ -1014,7 +1024,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match") match: varchar("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() .$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), .notNull(),
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
@@ -1594,3 +1612,4 @@ export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>; export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
+20 -2
View File
@@ -1224,7 +1224,17 @@ export const resourceRules = sqliteTable("resourceRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS 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() value: text("value").notNull()
}); });
@@ -1271,7 +1281,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match") match: text("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() .$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), .notNull(),
value: text("value").notNull() value: text("value").notNull()
}); });
+32 -34
View File
@@ -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 { import {
domains,
domainNamespaces, domainNamespaces,
domains,
orgDomains, orgDomains,
Resource, Resource,
resourceHeaderAuth, resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode, resourcePincode,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
resourceRules, resourceRules,
resources,
resourceWhitelist, resourceWhitelist,
roleActions, roleActions,
rolePolicies,
roleResources, roleResources,
roles, roles,
sites,
Target, Target,
TargetHealthCheck, TargetHealthCheck,
targetHealthCheck, targetHealthCheck,
targets,
Transaction, Transaction,
userOrgs, userOrgs,
userPolicies,
userResources, userResources,
users, users,
resourcePolicies, type ResourceRule
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyHeaderAuth,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
userPolicies
} from "@server/db"; } 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 { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; 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 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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import next from "next"; import next from "next";
@@ -909,7 +907,7 @@ export async function updatePublicResources(
.update(resourceRules) .update(resourceRules)
.set({ .set({
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -928,7 +926,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({ await trx.insert(resourceRules).values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -1011,7 +1009,7 @@ export async function updatePublicResources(
} else { } else {
// create a brand new resource // create a brand new resource
if (build == "saas") { if (build === "saas") {
const usage = await usageService.getUsage( const usage = await usageService.getUsage(
orgId, orgId,
LimitId.PUBLIC_RESOURCES LimitId.PUBLIC_RESOURCES
@@ -1292,7 +1290,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({ await trx.insert(resourceRules).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -1355,7 +1353,7 @@ function getRuleAction(input: string) {
function getRuleValue(match: string, value: string) { function getRuleValue(match: string, value: string) {
// if the match is a country, uppercase the value // if the match is a country, uppercase the value
if (match == "COUNTRY") { if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase(); return value.toUpperCase();
} }
return value; return value;
+2 -1
View File
@@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
function getRuleMatch( function getRuleMatch(
input: string input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" { ): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as return input.toUpperCase() as
| "CIDR" | "CIDR"
| "IP" | "IP"
| "PATH" | "PATH"
| "COUNTRY" | "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION"; | "REGION";
} }
+2
View File
@@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"IP", "IP",
"PATH", "PATH",
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION"
] as const; ] as const;
@@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError(
case "REGION": case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided"; return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY": case "COUNTRY":
case "COUNTRY_IS_NOT":
return COUNTRIES.some((country) => country.code === value) return COUNTRIES.some((country) => country.code === value)
? null ? null
: "Invalid country code provided"; : "Invalid country code provided";
+1
View File
@@ -1695,6 +1695,7 @@ hybridRouter.get(
) { ) {
for (const rule of rules) { for (const rule of rules) {
if (rule.match == "COUNTRY") { if (rule.match == "COUNTRY") {
// @ts-expect-error this is for backward compatibility
rule.match = "GEOIP"; rule.match = "GEOIP";
} }
} }
+8 -2
View File
@@ -1054,7 +1054,10 @@ async function checkRules(
isPathAllowed(rule.value, path) isPathAllowed(rule.value, path)
) { ) {
return rule.action as any; 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. // COUNTRY=ALL should not affect local/private/CGNAT addresses.
if ( if (
rule.value.toUpperCase() === "ALL" && rule.value.toUpperCase() === "ALL" &&
@@ -1063,7 +1066,10 @@ async function checkRules(
continue; 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; return rule.action as any;
} }
} else if (clientIp && rule.match == "ASN") { } else if (clientIp && rule.match == "ASN") {
@@ -27,6 +27,7 @@ const resourceRuleMatchSchema = z.enum([
"IP", "IP",
"PATH", "PATH",
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION"
]); ]);
@@ -144,6 +145,7 @@ export async function updateResourceRule(
| "IP" | "IP"
| "PATH" | "PATH"
| "COUNTRY" | "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION"; | "REGION";
@@ -75,6 +75,7 @@ import {
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import type { readonly } from "zod";
export type PolicyAccessRulesTableProps = { export type PolicyAccessRulesTableProps = {
rules: PolicyAccessRule[]; rules: PolicyAccessRule[];
@@ -106,7 +107,7 @@ function getColumnClassName(columnId: string) {
return "w-42 max-w-42"; return "w-42 max-w-42";
} }
if (columnId === "match") { if (columnId === "match") {
return "w-36 max-w-36"; return "w-42 max-w-42";
} }
return ""; return "";
} }
@@ -230,6 +231,7 @@ export function PolicyAccessRulesTable({
IP: "IP", IP: "IP",
CIDR: t("ipAddressRange"), CIDR: t("ipAddressRange"),
COUNTRY: t("country"), COUNTRY: t("country"),
COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN", ASN: "ASN",
REGION: t("region") REGION: t("region")
}), }),
@@ -442,13 +444,15 @@ export function PolicyAccessRulesTable({
| "IP" | "IP"
| "PATH" | "PATH"
| "COUNTRY" | "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION" | "REGION"
) => ) =>
updateRule(row.original.ruleId, { updateRule(row.original.ruleId, {
match: value, match: value,
value: value:
value === "COUNTRY" value === "COUNTRY" ||
value === "COUNTRY_IS_NOT"
? "US" ? "US"
: value === "ASN" : value === "ASN"
? "AS15169" ? "AS15169"
@@ -458,7 +462,7 @@ export function PolicyAccessRulesTable({
}) })
} }
> >
<SelectTrigger className="h-8 w-full min-w-0"> <SelectTrigger className="h-8 w-36 min-w-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -470,9 +474,14 @@ export function PolicyAccessRulesTable({
{RuleMatch.CIDR} {RuleMatch.CIDR}
</SelectItem> </SelectItem>
{isMaxmindAvailable && ( {isMaxmindAvailable && (
<SelectItem value="COUNTRY"> <>
{RuleMatch.COUNTRY} <SelectItem value="COUNTRY">
</SelectItem> {RuleMatch.COUNTRY}
</SelectItem>
<SelectItem value="COUNTRY_IS_NOT">
{RuleMatch.COUNTRY_IS_NOT}
</SelectItem>
</>
)} )}
{includeRegionMatch && isMaxmindAvailable && ( {includeRegionMatch && isMaxmindAvailable && (
<SelectItem value="REGION"> <SelectItem value="REGION">
@@ -494,14 +503,16 @@ export function PolicyAccessRulesTable({
cell: ({ row }) => { cell: ({ row }) => {
let selectedCountry: (typeof COUNTRIES)[number] | undefined; let selectedCountry: (typeof COUNTRIES)[number] | undefined;
if ( if (
row.original.match === "COUNTRY" && (row.original.match === "COUNTRY" ||
row.original.match === "COUNTRY_IS_NOT") &&
row.original.value row.original.value
) { ) {
selectedCountry = COUNTRIES.find( selectedCountry = COUNTRIES.find(
(c) => c.code === row.original.value (c) => c.code === row.original.value
); );
} }
return row.original.match === "COUNTRY" ? ( return row.original.match === "COUNTRY" ||
row.original.match === "COUNTRY_IS_NOT" ? (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -17,6 +17,7 @@ export const POLICY_RULE_MATCH_TYPES = [
"IP", "IP",
"PATH", "PATH",
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION"
] as const; ] as const;
@@ -78,6 +79,7 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
message: t("rulesErrorInvalidRegionDescription") message: t("rulesErrorInvalidRegionDescription")
}); });
case "COUNTRY": case "COUNTRY":
case "COUNTRY_IS_NOT":
return required.refine( return required.refine(
(value) => COUNTRIES.some((country) => country.code === value), (value) => COUNTRIES.some((country) => country.code === value),
{ message: t("rulesErrorInvalidCountryDescription") } { message: t("rulesErrorInvalidCountryDescription") }