Add ASN-based resource rule matching

- Add MaxMind ASN database integration
- Implement ASN lookup and matching in resource rule verification
- Add curated list of 100+ major ASNs (cloud, ISP, CDN, mobile carriers)
- Add ASN dropdown selector in resource rules UI with search functionality
- Support custom ASN input for unlisted ASNs
- Add 'ALL ASNs' special case handling (AS0)
- Cache ASN lookups with 5-minute TTL for performance
- Update validation schemas to support ASN match type

This allows administrators to create resource access rules based on
Autonomous System Numbers, similar to existing country-based rules.
Useful for restricting access by ISP, cloud provider, or mobile carrier.
This commit is contained in:
Thomas Wilde
2025-12-16 11:18:54 -07:00
committed by Owen Schwartz
parent 1f4ebf1907
commit 8d2955475b
11 changed files with 678 additions and 9 deletions

View File

@@ -74,6 +74,7 @@ import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { COUNTRIES } from "@server/db/countries";
import { MAJOR_ASNS } from "@server/db/asns";
import {
Command,
CommandEmpty,
@@ -116,11 +117,15 @@ export default function ResourceRules(props: {
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
useState(false);
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const isMaxmindAvailable =
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
const isMaxmindAsnAvailable =
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0;
const RuleAction = {
ACCEPT: t("alwaysAllow"),
@@ -132,7 +137,8 @@ export default function ResourceRules(props: {
PATH: t("path"),
IP: "IP",
CIDR: t("ipAddressRange"),
COUNTRY: t("country")
COUNTRY: t("country"),
ASN: "ASN"
} as const;
const addRuleForm = useForm({
@@ -171,6 +177,30 @@ export default function ResourceRules(props: {
}, []);
async function addRule(data: z.infer<typeof addRuleSchema>) {
// Normalize ASN value
if (data.match === "ASN") {
const originalValue = data.value.toUpperCase();
// Handle special "ALL" case
if (originalValue === "ALL" || originalValue === "AS0") {
data.value = "ALL";
} else {
// Remove AS prefix if present
const normalized = originalValue.replace(/^AS/, "");
if (!/^\d+$/.test(normalized)) {
toast({
variant: "destructive",
title: "Invalid ASN",
description:
"ASN must be a number, optionally prefixed with 'AS' (e.g., AS15169 or 15169), or 'ALL'"
});
return;
}
// Add "AS" prefix for consistent storage
data.value = "AS" + normalized;
}
}
const isDuplicate = rules.some(
(rule) =>
rule.action === data.action &&
@@ -279,6 +309,8 @@ export default function ResourceRules(props: {
return t("rulesMatchUrl");
case "COUNTRY":
return t("rulesMatchCountry");
case "ASN":
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
}
}
@@ -504,12 +536,12 @@ export default function ResourceRules(props: {
<Select
defaultValue={row.original.match}
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY"
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY" ? "US" : row.original.value
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : row.original.value
})
}
>
@@ -525,6 +557,11 @@ export default function ResourceRules(props: {
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
</SelectItem>
)}
</SelectContent>
</Select>
)
@@ -591,6 +628,93 @@ export default function ResourceRules(props: {
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "ASN" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-[200px] justify-between"
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[200px] p-0">
<Command>
<CommandInput
placeholder="Search ASNs or enter custom..."
/>
<CommandList>
<CommandEmpty>
No ASN found. Enter a custom ASN below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map((asn) => (
<CommandItem
key={asn.code}
value={asn.name + " " + asn.code}
onSelect={() => {
updateRule(
row.original.ruleId,
{ value: asn.code }
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value ===
asn.code
? "opacity-100"
: "opacity-0"
}`}
/>
{asn.name} ({asn.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
defaultValue={
!MAJOR_ASNS.find(
(asn) =>
asn.code === row.original.value
)
? row.original.value
: ""
}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = e.currentTarget.value
.toUpperCase()
.replace(/^AS/, "");
if (/^\d+$/.test(value)) {
updateRule(
row.original.ruleId,
{ value: "AS" + value }
);
}
}
}}
className="text-sm"
/>
</div>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
@@ -801,6 +925,13 @@ export default function ResourceRules(props: {
}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{
RuleMatch.ASN
}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
@@ -923,6 +1054,115 @@ export default function ResourceRules(props: {
</Command>
</PopoverContent>
</Popover>
) : addRuleForm.watch(
"match"
) === "ASN" ? (
<Popover
open={
openAddRuleAsnSelect
}
onOpenChange={
setOpenAddRuleAsnSelect
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
aria-expanded={
openAddRuleAsnSelect
}
className="w-full justify-between"
>
{field.value
? MAJOR_ASNS.find(
(
asn
) =>
asn.code ===
field.value
)
?.name +
" (" +
field.value +
")" || field.value
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search ASNs or enter custom..."
/>
<CommandList>
<CommandEmpty>
No ASN found. Use the custom input below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map(
(
asn
) => (
<CommandItem
key={
asn.code
}
value={
asn.name + " " + asn.code
}
onSelect={() => {
field.onChange(
asn.code
);
setOpenAddRuleAsnSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
asn.code
? "opacity-100"
: "opacity-0"
}`}
/>
{
asn.name
}{" "}
(
{
asn.code
}
)
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = e.currentTarget.value
.toUpperCase()
.replace(/^AS/, "");
if (/^\d+$/.test(value)) {
field.onChange("AS" + value);
setOpenAddRuleAsnSelect(false);
}
}
}}
className="text-sm"
/>
</div>
</PopoverContent>
</Popover>
) : (
<Input {...field} />
)}

View File

@@ -15,7 +15,8 @@ export function pullEnv(): Env {
resourceAccessTokenHeadersToken: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string,
reoClientId: process.env.REO_CLIENT_ID as string,
maxmind_db_path: process.env.MAXMIND_DB_PATH as string
maxmind_db_path: process.env.MAXMIND_DB_PATH as string,
maxmind_asn_path: process.env.MAXMIND_ASN_PATH as string
},
app: {
environment: process.env.ENVIRONMENT as string,

View File

@@ -19,6 +19,7 @@ export type Env = {
resourceAccessTokenHeadersToken: string;
reoClientId?: string;
maxmind_db_path?: string;
maxmind_asn_path?: string;
};
email: {
emailEnabled: boolean;