Repository: goauthentik/authentik
Pull request: policies: add GeoIP policy
Closes #4433 

Introduces the new GeoIP policy with the functionality to match client ASN or country (for now).

~Opening a draft of this for a high-level overview. There are still many details to iron out like~

-   [x] Back-end tests
-   [x] Handle disabled geoip
-   [x] Handle IP missing in geoip database
-   [x] Handle empty lists of ASNs and countries
-   [x] Translations
-   [x] Types on `web`
-   [x] Multiselect on `web`

---

## Checklist

-   [x] Local tests pass (`ak test authentik/`)
-   [x] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [x] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [x] The code has been formatted (`make web`)

If applicable

-   [x] The documentation has been updated
-   [x] The documentation has been formatted (`make website`)


Problem statement:
Dedicated GeoIP/ASN policy
**Is your feature request related to a problem? Please describe.**
Since the GeoIP DB is now bundled with authentik and is available out of the box, it is logical to assume that even more users will want to take advantage of what GeoIP can offer. 

However, currently the only way to use the GeoIP features is through an expression policy. The problem with that is:
1. Not flexible. To change rules you need to either edit the code in the policy directly or have some kind of a system where it can take settings from environment variables or tenant attributes.
2. Requires programming knowledge

**Describe the solution you'd like**
A dedicated GeoIP policy which can be used to set rules for passing/failing. 

**Describe alternatives you've considered**
Write/update code in an expression policy

**Additional context**
N/A

Hints from issue discussion:

Commit message: fix translations

whoops

Patch to review:
diff --git a/authentik/policies/geoip/__init__.py b/authentik/policies/geoip/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/authentik/policies/geoip/api.py b/authentik/policies/geoip/api.py
new file mode 100644
index 000000000000..54ee3d216930
--- /dev/null
+++ b/authentik/policies/geoip/api.py
@@ -0,0 +1,30 @@
+"""GeoIP Policy API Views"""
+
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.core.api.used_by import UsedByMixin
+from authentik.policies.api.policies import PolicySerializer
+from authentik.policies.geoip.models import GeoIPPolicy
+
+
+class GeoIPPolicySerializer(PolicySerializer):
+    """GeoIP Policy Serializer"""
+
+    class Meta:
+        model = GeoIPPolicy
+        fields = PolicySerializer.Meta.fields + [
+            "asn_mode",
+            "asn_list",
+            "country_mode",
+            "country_list",
+        ]
+
+
+class GeoIPPolicyViewSet(UsedByMixin, ModelViewSet):
+    """GeoIP Viewset"""
+
+    queryset = GeoIPPolicy.objects.all()
+    serializer_class = GeoIPPolicySerializer
+    filterset_fields = ["name"]
+    ordering = ["name"]
+    search_fields = ["name"]
diff --git a/authentik/policies/geoip/apps.py b/authentik/policies/geoip/apps.py
new file mode 100644
index 000000000000..ae64d29d4e40
--- /dev/null
+++ b/authentik/policies/geoip/apps.py
@@ -0,0 +1,11 @@
+"""Authentik policy geoip app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyGeoIPConfig(AppConfig):
+    """Authentik policy_geoip app config"""
+
+    name = "authentik.policies.geoip"
+    label = "authentik_policies_geoip"
+    verbose_name = "authentik Policies.GeoIP"
diff --git a/authentik/policies/geoip/countries.py b/authentik/policies/geoip/countries.py
new file mode 100644
index 000000000000..71e998a269a8
--- /dev/null
+++ b/authentik/policies/geoip/countries.py
@@ -0,0 +1,253 @@
+"""List of all ISO-3166-1 alpha-2 country codes"""
+
+COUNTRIES = {
+    "AF": "Afghanistan",
+    "AX": "Åland Islands",
+    "AL": "Albania",
+    "DZ": "Algeria",
+    "AS": "American Samoa",
+    "AD": "Andorra",
+    "AO": "Angola",
+    "AI": "Anguilla",
+    "AQ": "Antarctica",
+    "AG": "Antigua & Barbuda",
+    "AR": "Argentina",
+    "AM": "Armenia",
+    "AW": "Aruba",
+    "AU": "Australia",
+    "AT": "Austria",
+    "AZ": "Azerbaijan",
+    "BS": "Bahamas",
+    "BH": "Bahrain",
+    "BD": "Bangladesh",
+    "BB": "Barbados",
+    "BY": "Belarus",
+    "BE": "Belgium",
+    "BZ": "Belize",
+    "BJ": "Benin",
+    "BM": "Bermuda",
+    "BT": "Bhutan",
+    "BO": "Bolivia",
+    "BA": "Bosnia & Herzegovina",
+    "BW": "Botswana",
+    "BV": "Bouvet Island",
+    "BR": "Brazil",
+    "IO": "British Indian Ocean Territory",
+    "VG": "British Virgin Islands",
+    "BN": "Brunei",
+    "BG": "Bulgaria",
+    "BF": "Burkina Faso",
+    "BI": "Burundi",
+    "KH": "Cambodia",
+    "CM": "Cameroon",
+    "CA": "Canada",
+    "CV": "Cape Verde",
+    "BQ": "Caribbean Netherlands",
+    "KY": "Cayman Islands",
+    "CF": "Central African Republic",
+    "TD": "Chad",
+    "CL": "Chile",
+    "CN": "China",
+    "CX": "Christmas Island",
+    "CC": "Cocos (Keeling) Islands",
+    "CO": "Colombia",
+    "KM": "Comoros",
+    "CG": "Congo - Brazzaville",
+    "CD": "Congo - Kinshasa",
+    "CK": "Cook Islands",
+    "CR": "Costa Rica",
+    "CI": "Côte d’Ivoire",
+    "HR": "Croatia",
+    "CU": "Cuba",
+    "CW": "Curaçao",
+    "CY": "Cyprus",
+    "CZ": "Czechia",
+    "DK": "Denmark",
+    "DJ": "Djibouti",
+    "DM": "Dominica",
+    "DO": "Dominican Republic",
+    "EC": "Ecuador",
+    "EG": "Egypt",
+    "SV": "El Salvador",
+    "GQ": "Equatorial Guinea",
+    "ER": "Eritrea",
+    "EE": "Estonia",
+    "SZ": "Eswatini",
+    "ET": "Ethiopia",
+    "FK": "Falkland Islands",
+    "FO": "Faroe Islands",  # codespell:ignore
+    "FJ": "Fiji",
+    "FI": "Finland",
+    "FR": "France",
+    "GF": "French Guiana",
+    "PF": "French Polynesia",
+    "TF": "French Southern Territories",
+    "GA": "Gabon",
+    "GM": "Gambia",
+    "GE": "Georgia",
+    "DE": "Germany",
+    "GH": "Ghana",
+    "GI": "Gibraltar",
+    "GR": "Greece",
+    "GL": "Greenland",
+    "GD": "Grenada",
+    "GP": "Guadeloupe",
+    "GU": "Guam",
+    "GT": "Guatemala",
+    "GG": "Guernsey",
+    "GN": "Guinea",
+    "GW": "Guinea-Bissau",
+    "GY": "Guyana",
+    "HT": "Haiti",
+    "HM": "Heard & McDonald Islands",
+    "HN": "Honduras",
+    "HK": "Hong Kong SAR China",
+    "HU": "Hungary",
+    "IS": "Iceland",
+    "IN": "India",
+    "ID": "Indonesia",
+    "IR": "Iran",
+    "IQ": "Iraq",
+    "IE": "Ireland",
+    "IM": "Isle of Man",
+    "IL": "Israel",
+    "IT": "Italy",
+    "JM": "Jamaica",
+    "JP": "Japan",
+    "JE": "Jersey",
+    "JO": "Jordan",
+    "KZ": "Kazakhstan",
+    "KE": "Kenya",
+    "KI": "Kiribati",
+    "KW": "Kuwait",
+    "KG": "Kyrgyzstan",
+    "LA": "Laos",
+    "LV": "Latvia",
+    "LB": "Lebanon",
+    "LS": "Lesotho",
+    "LR": "Liberia",
+    "LY": "Libya",
+    "LI": "Liechtenstein",
+    "LT": "Lithuania",
+    "LU": "Luxembourg",
+    "MO": "Macao SAR China",
+    "MG": "Madagascar",
+    "MW": "Malawi",
+    "MY": "Malaysia",
+    "MV": "Maldives",
+    "ML": "Mali",
+    "MT": "Malta",
+    "MH": "Marshall Islands",
+    "MQ": "Martinique",
+    "MR": "Mauritania",
+    "MU": "Mauritius",
+    "YT": "Mayotte",
+    "MX": "Mexico",
+    "FM": "Micronesia",
+    "MD": "Moldova",
+    "MC": "Monaco",
+    "MN": "Mongolia",
+    "ME": "Montenegro",
+    "MS": "Montserrat",
+    "MA": "Morocco",
+    "MZ": "Mozambique",
+    "MM": "Myanmar (Burma)",
+    "NA": "Namibia",
+    "NR": "Nauru",
+    "NP": "Nepal",
+    "NL": "Netherlands",
+    "NC": "New Caledonia",
+    "NZ": "New Zealand",
+    "NI": "Nicaragua",
+    "NE": "Niger",
+    "NG": "Nigeria",
+    "NU": "Niue",
+    "NF": "Norfolk Island",
+    "KP": "North Korea",
+    "MK": "North Macedonia",
+    "MP": "Northern Mariana Islands",
+    "NO": "Norway",
+    "OM": "Oman",
+    "PK": "Pakistan",
+    "PW": "Palau",
+    "PS": "Palestinian Territories",
+    "PA": "Panama",
+    "PG": "Papua New Guinea",
+    "PY": "Paraguay",
+    "PE": "Peru",
+    "PH": "Philippines",
+    "PN": "Pitcairn Islands",
+    "PL": "Poland",
+    "PT": "Portugal",
+    "PR": "Puerto Rico",
+    "QA": "Qatar",
+    "RE": "Réunion",
+    "RO": "Romania",
+    "RU": "Russia",
+    "RW": "Rwanda",
+    "WS": "Samoa",
+    "SM": "San Marino",
+    "ST": "São Tomé & Príncipe",
+    "SA": "Saudi Arabia",
+    "SN": "Senegal",
+    "RS": "Serbia",
+    "SC": "Seychelles",
+    "SL": "Sierra Leone",
+    "SG": "Singapore",
+    "SX": "Sint Maarten",
+    "SK": "Slovakia",
+    "SI": "Slovenia",
+    "SB": "Solomon Islands",
+    "SO": "Somalia",
+    "ZA": "South Africa",
+    "GS": "South Georgia & South Sandwich Islands",
+    "KR": "South Korea",
+    "SS": "South Sudan",
+    "ES": "Spain",
+    "LK": "Sri Lanka",
+    "BL": "St. Barthélemy",
+    "SH": "St. Helena",
+    "KN": "St. Kitts & Nevis",
+    "LC": "St. Lucia",
+    "MF": "St. Martin",
+    "PM": "St. Pierre & Miquelon",
+    "VC": "St. Vincent & Grenadines",
+    "SD": "Sudan",
+    "SR": "Suriname",
+    "SJ": "Svalbard & Jan Mayen",
+    "SE": "Sweden",
+    "CH": "Switzerland",
+    "SY": "Syria",
+    "TW": "Taiwan",
+    "TJ": "Tajikistan",
+    "TZ": "Tanzania",
+    "TH": "Thailand",
+    "TL": "Timor-Leste",
+    "TG": "Togo",
+    "TK": "Tokelau",
+    "TO": "Tonga",
+    "TT": "Trinidad & Tobago",
+    "TN": "Tunisia",
+    "TR": "Turkey",
+    "TM": "Turkmenistan",
+    "TC": "Turks & Caicos Islands",
+    "TV": "Tuvalu",
+    "UM": "U.S. Outlying Islands",
+    "VI": "U.S. Virgin Islands",
+    "UG": "Uganda",
+    "UA": "Ukraine",
+    "AE": "United Arab Emirates",
+    "GB": "United Kingdom",
+    "US": "United States",
+    "UY": "Uruguay",
+    "UZ": "Uzbekistan",
+    "VU": "Vanuatu",
+    "VA": "Vatican City",
+    "VE": "Venezuela",
+    "VN": "Vietnam",
+    "WF": "Wallis & Futuna",
+    "EH": "Western Sahara",
+    "YE": "Yemen",
+    "ZM": "Zambia",
+    "ZW": "Zimbabwe",
+}
diff --git a/authentik/policies/geoip/exceptions.py b/authentik/policies/geoip/exceptions.py
new file mode 100644
index 000000000000..a7359026326d
--- /dev/null
+++ b/authentik/policies/geoip/exceptions.py
@@ -0,0 +1,5 @@
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class GeoIPNotFoundException(SentryIgnoredException):
+    """Exception raised when an IP is not found in a GeoIP database"""
diff --git a/authentik/policies/geoip/migrations/0001_initial.py b/authentik/policies/geoip/migrations/0001_initial.py
new file mode 100644
index 000000000000..50521e12fe10
--- /dev/null
+++ b/authentik/policies/geoip/migrations/0001_initial.py
@@ -0,0 +1,327 @@
+# Generated by Django 5.0.7 on 2024-07-15 07:20
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("authentik_policies", "0011_policybinding_failure_result_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="GeoIPPolicy",
+            fields=[
+                (
+                    "policy_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="authentik_policies.policy",
+                    ),
+                ),
+                (
+                    "asn_mode",
+                    models.TextField(
+                        choices=[
+                            ("allow", "Only allow connections from entities"),
+                            ("block", "Block connections from entities"),
+                        ]
+                    ),
+                ),
+                (
+                    "country_mode",
+                    models.TextField(
+                        choices=[
+                            ("allow", "Only allow connections from entities"),
+                            ("block", "Block connections from entities"),
+                        ]
+                    ),
+                ),
+                (
+                    "asn_list",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.IntegerField(), blank=True, default=list, size=None
+                    ),
+                ),
+                (
+                    "country_list",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.CharField(
+                            choices=[
+                                ("AF", "Afghanistan"),
+                                ("AX", "Åland Islands"),
+                                ("AL", "Albania"),
+                                ("DZ", "Algeria"),
+                                ("AS", "American Samoa"),
+                                ("AD", "Andorra"),
+                                ("AO", "Angola"),
+                                ("AI", "Anguilla"),
+                                ("AQ", "Antarctica"),
+                                ("AG", "Antigua & Barbuda"),
+                                ("AR", "Argentina"),
+                                ("AM", "Armenia"),
+                                ("AW", "Aruba"),
+                                ("AU", "Australia"),
+                                ("AT", "Austria"),
+                                ("AZ", "Azerbaijan"),
+                                ("BS", "Bahamas"),
+                                ("BH", "Bahrain"),
+                                ("BD", "Bangladesh"),
+                                ("BB", "Barbados"),
+                                ("BY", "Belarus"),
+                                ("BE", "Belgium"),
+                                ("BZ", "Belize"),
+                                ("BJ", "Benin"),
+                                ("BM", "Bermuda"),
+                                ("BT", "Bhutan"),
+                                ("BO", "Bolivia"),
+                                ("BA", "Bosnia & Herzegovina"),
+                                ("BW", "Botswana"),
+                                ("BV", "Bouvet Island"),
+                                ("BR", "Brazil"),
+                                ("IO", "British Indian Ocean Territory"),
+                                ("VG", "British Virgin Islands"),
+                                ("BN", "Brunei"),
+                                ("BG", "Bulgaria"),
+                                ("BF", "Burkina Faso"),
+                                ("BI", "Burundi"),
+                                ("KH", "Cambodia"),
+                                ("CM", "Cameroon"),
+                                ("CA", "Canada"),
+                                ("CV", "Cape Verde"),
+                                ("BQ", "Caribbean Netherlands"),
+                                ("KY", "Cayman Islands"),
+                                ("CF", "Central African Republic"),
+                                ("TD", "Chad"),
+                                ("CL", "Chile"),
+                                ("CN", "China"),
+                                ("CX", "Christmas Island"),
+                                ("CC", "Cocos (Keeling) Islands"),
+                                ("CO", "Colombia"),
+                                ("KM", "Comoros"),
+                                ("CG", "Congo - Brazzaville"),
+                                ("CD", "Congo - Kinshasa"),
+                                ("CK", "Cook Islands"),
+                                ("CR", "Costa Rica"),
+                                ("CI", "Côte d’Ivoire"),
+                                ("HR", "Croatia"),
+                                ("CU", "Cuba"),
+                                ("CW", "Curaçao"),
+                                ("CY", "Cyprus"),
+                                ("CZ", "Czechia"),
+                                ("DK", "Denmark"),
+                                ("DJ", "Djibouti"),
+                                ("DM", "Dominica"),
+                                ("DO", "Dominican Republic"),
+                                ("EC", "Ecuador"),
+                                ("EG", "Egypt"),
+                                ("SV", "El Salvador"),
+                                ("GQ", "Equatorial Guinea"),
+                                ("ER", "Eritrea"),
+                                ("EE", "Estonia"),
+                                ("SZ", "Eswatini"),
+                                ("ET", "Ethiopia"),
+                                ("FK", "Falkland Islands"),
+                                ("FO", "Faroe Islands"),  # codespell:ignore
+                                ("FJ", "Fiji"),
+                                ("FI", "Finland"),
+                                ("FR", "France"),
+                                ("GF", "French Guiana"),
+                                ("PF", "French Polynesia"),
+                                ("TF", "French Southern Territories"),
+                                ("GA", "Gabon"),
+                                ("GM", "Gambia"),
+                                ("GE", "Georgia"),
+                                ("DE", "Germany"),
+                                ("GH", "Ghana"),
+                                ("GI", "Gibraltar"),
+                                ("GR", "Greece"),
+                                ("GL", "Greenland"),
+                                ("GD", "Grenada"),
+                                ("GP", "Guadeloupe"),
+                                ("GU", "Guam"),
+                                ("GT", "Guatemala"),
+                                ("GG", "Guernsey"),
+                                ("GN", "Guinea"),
+                                ("GW", "Guinea-Bissau"),
+                                ("GY", "Guyana"),
+                                ("HT", "Haiti"),
+                                ("HM", "Heard & McDonald Islands"),
+                                ("HN", "Honduras"),
+                                ("HK", "Hong Kong SAR China"),
+                                ("HU", "Hungary"),
+                                ("IS", "Iceland"),
+                                ("IN", "India"),
+                                ("ID", "Indonesia"),
+                                ("IR", "Iran"),
+                                ("IQ", "Iraq"),
+                                ("IE", "Ireland"),
+                                ("IM", "Isle of Man"),
+                                ("IL", "Israel"),
+                                ("IT", "Italy"),
+                                ("JM", "Jamaica"),
+                                ("JP", "Japan"),
+                                ("JE", "Jersey"),
+                                ("JO", "Jordan"),
+                                ("KZ", "Kazakhstan"),
+                                ("KE", "Kenya"),
+                                ("KI", "Kiribati"),
+                                ("KW", "Kuwait"),
+                                ("KG", "Kyrgyzstan"),
+                                ("LA", "Laos"),
+                                ("LV", "Latvia"),
+                                ("LB", "Lebanon"),
+                                ("LS", "Lesotho"),
+                                ("LR", "Liberia"),
+                                ("LY", "Libya"),
+                                ("LI", "Liechtenstein"),
+                                ("LT", "Lithuania"),
+                                ("LU", "Luxembourg"),
+                                ("MO", "Macao SAR China"),
+                                ("MG", "Madagascar"),
+                                ("MW", "Malawi"),
+                                ("MY", "Malaysia"),
+                                ("MV", "Maldives"),
+                                ("ML", "Mali"),
+                                ("MT", "Malta"),
+                                ("MH", "Marshall Islands"),
+                                ("MQ", "Martinique"),
+                                ("MR", "Mauritania"),
+                                ("MU", "Mauritius"),
+                                ("YT", "Mayotte"),
+                                ("MX", "Mexico"),
+                                ("FM", "Micronesia"),
+                                ("MD", "Moldova"),
+                                ("MC", "Monaco"),
+                                ("MN", "Mongolia"),
+                                ("ME", "Montenegro"),
+                                ("MS", "Montserrat"),
+                                ("MA", "Morocco"),
+                                ("MZ", "Mozambique"),
+                                ("MM", "Myanmar (Burma)"),
+                                ("NA", "Namibia"),
+                                ("NR", "Nauru"),
+                                ("NP", "Nepal"),
+                                ("NL", "Netherlands"),
+                                ("NC", "New Caledonia"),
+                                ("NZ", "New Zealand"),
+                                ("NI", "Nicaragua"),
+                                ("NE", "Niger"),
+                                ("NG", "Nigeria"),
+                                ("NU", "Niue"),
+                                ("NF", "Norfolk Island"),
+                                ("KP", "North Korea"),
+                                ("MK", "North Macedonia"),
+                                ("MP", "Northern Mariana Islands"),
+                                ("NO", "Norway"),
+                                ("OM", "Oman"),
+                                ("PK", "Pakistan"),
+                                ("PW", "Palau"),
+                                ("PS", "Palestinian Territories"),
+                                ("PA", "Panama"),
+                                ("PG", "Papua New Guinea"),
+                                ("PY", "Paraguay"),
+                                ("PE", "Peru"),
+                                ("PH", "Philippines"),
+                                ("PN", "Pitcairn Islands"),
+                                ("PL", "Poland"),
+                                ("PT", "Portugal"),
+                                ("PR", "Puerto Rico"),
+                                ("QA", "Qatar"),
+                                ("RE", "Réunion"),
+                                ("RO", "Romania"),
+                                ("RU", "Russia"),
+                                ("RW", "Rwanda"),
+                                ("WS", "Samoa"),
+                                ("SM", "San Marino"),
+                                ("ST", "São Tomé & Príncipe"),
+                                ("SA", "Saudi Arabia"),
+                                ("SN", "Senegal"),
+                                ("RS", "Serbia"),
+                                ("SC", "Seychelles"),
+                                ("SL", "Sierra Leone"),
+                                ("SG", "Singapore"),
+                                ("SX", "Sint Maarten"),
+                                ("SK", "Slovakia"),
+                                ("SI", "Slovenia"),
+                                ("SB", "Solomon Islands"),
+                                ("SO", "Somalia"),
+                                ("ZA", "South Africa"),
+                                ("GS", "South Georgia & South Sandwich Islands"),
+                                ("KR", "South Korea"),
+                                ("SS", "South Sudan"),
+                                ("ES", "Spain"),
+                                ("LK", "Sri Lanka"),
+                                ("BL", "St. Barthélemy"),
+                                ("SH", "St. Helena"),
+                                ("KN", "St. Kitts & Nevis"),
+                                ("LC", "St. Lucia"),
+                                ("MF", "St. Martin"),
+                                ("PM", "St. Pierre & Miquelon"),
+                                ("VC", "St. Vincent & Grenadines"),
+                                ("SD", "Sudan"),
+                                ("SR", "Suriname"),
+                                ("SJ", "Svalbard & Jan Mayen"),
+                                ("SE", "Sweden"),
+                                ("CH", "Switzerland"),
+                                ("SY", "Syria"),
+                                ("TW", "Taiwan"),
+                                ("TJ", "Tajikistan"),
+                                ("TZ", "Tanzania"),
+                                ("TH", "Thailand"),
+                                ("TL", "Timor-Leste"),
+                                ("TG", "Togo"),
+                                ("TK", "Tokelau"),
+                                ("TO", "Tonga"),
+                                ("TT", "Trinidad & Tobago"),
+                                ("TN", "Tunisia"),
+                                ("TR", "Turkey"),
+                                ("TM", "Turkmenistan"),
+                                ("TC", "Turks & Caicos Islands"),
+                                ("TV", "Tuvalu"),
+                                ("UM", "U.S. Outlying Islands"),
+                                ("VI", "U.S. Virgin Islands"),
+                                ("UG", "Uganda"),
+                                ("UA", "Ukraine"),
+                                ("AE", "United Arab Emirates"),
+                                ("GB", "United Kingdom"),
+                                ("US", "United States"),
+                                ("UY", "Uruguay"),
+                                ("UZ", "Uzbekistan"),
+                                ("VU", "Vanuatu"),
+                                ("VA", "Vatican City"),
+                                ("VE", "Venezuela"),
+                                ("VN", "Vietnam"),
+                                ("WF", "Wallis & Futuna"),
+                                ("EH", "Western Sahara"),
+                                ("YE", "Yemen"),
+                                ("ZM", "Zambia"),
+                                ("ZW", "Zimbabwe"),
+                            ],
+                            max_length=2,
+                        ),
+                        blank=True,
+                        default=list,
+                        size=None,
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "GeoIP Policy",
+                "verbose_name_plural": "GeoIP Policies",
+                "indexes": [
+                    models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__5cc4a9_idx")
+                ],
+            },
+            bases=("authentik_policies.policy",),
+        ),
+    ]
diff --git a/authentik/policies/geoip/migrations/__init__.py b/authentik/policies/geoip/migrations/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/authentik/policies/geoip/models.py b/authentik/policies/geoip/models.py
new file mode 100644
index 000000000000..d6004f6cdc44
--- /dev/null
+++ b/authentik/policies/geoip/models.py
@@ -0,0 +1,81 @@
+"""GeoIP policy"""
+
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+
+from authentik.policies.exceptions import PolicyException
+from authentik.policies.geoip.countries import COUNTRIES
+from authentik.policies.geoip.exceptions import GeoIPNotFoundException
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class GeoIPPolicyMode(models.TextChoices):
+    """Block or only allow connections from entities"""
+
+    ALLOW = "allow", _("Only allow connections from entities")
+    BLOCK = "block", _("Block connections from entities")
+
+
+class GeoIPPolicy(Policy):
+    """Ensure the user satisfies requirements of geography or network topology, based on IP
+    address."""
+
+    asn_mode = models.TextField(choices=GeoIPPolicyMode.choices)
+    country_mode = models.TextField(choices=GeoIPPolicyMode.choices)
+    asn_list = ArrayField(models.IntegerField(), blank=True, default=list)
+    country_list = ArrayField(
+        models.CharField(choices=COUNTRIES, max_length=2), blank=True, default=list
+    )
+
+    @property
+    def serializer(self) -> type[BaseSerializer]:
+        from authentik.policies.geoip.api import GeoIPPolicySerializer
+
+        return GeoIPPolicySerializer
+
+    @property
+    def component(self) -> str:  # pragma: no cover
+        return "ak-policy-geoip-form"
+
+    def passes(self, request: PolicyRequest) -> PolicyResult:
+        """TODO"""
+
+        # This is not a single get chain because `request.context` can contain `{ "asn": None }`.
+        asn_data = request.context.get("asn")
+        geoip_data = request.context.get("geoip")
+        asn = asn_data.get("asn") if asn_data else None
+        country = geoip_data.get("country") if geoip_data else None
+
+        if not asn:
+            raise PolicyException(
+                GeoIPNotFoundException("GeoIP: client IP not found in ASN database.")
+            )
+        if not country:
+            raise PolicyException(
+                GeoIPNotFoundException("GeoIP: client IP address not found in City database.")
+            )
+
+        if self.asn_mode == GeoIPPolicyMode.ALLOW and asn not in self.asn_list:
+            message = _("Client IP is not part of an allowed autonomous system.")
+            return PolicyResult(False, message)
+
+        if self.asn_mode == GeoIPPolicyMode.BLOCK and asn in self.asn_list:
+            message = _("Client IP is part of a blocked autonomous system.")
+            return PolicyResult(False, message)
+
+        if self.country_mode == GeoIPPolicyMode.ALLOW and country not in self.country_list:
+            message = _("Client IP is not in an allowed country.")
+            return PolicyResult(False, message)
+
+        if self.country_mode == GeoIPPolicyMode.BLOCK and country in self.country_list:
+            message = _("Client IP is in a blocked country.")
+            return PolicyResult(False, message)
+
+        return PolicyResult(True)
+
+    class Meta(Policy.PolicyMeta):
+        verbose_name = _("GeoIP Policy")
+        verbose_name_plural = _("GeoIP Policies")
diff --git a/authentik/policies/geoip/urls.py b/authentik/policies/geoip/urls.py
new file mode 100644
index 000000000000..8a0316b33897
--- /dev/null
+++ b/authentik/policies/geoip/urls.py
@@ -0,0 +1,5 @@
+"""API URLs"""
+
+from authentik.policies.geoip.api import GeoIPPolicyViewSet
+
+api_urlpatterns = [("policies/geoip", GeoIPPolicyViewSet)]
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index dcfeae4a2f0c..a9ff910c7034 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -77,6 +77,7 @@
     "authentik.policies.event_matcher",
     "authentik.policies.expiry",
     "authentik.policies.expression",
+    "authentik.policies.geoip",
     "authentik.policies.password",
     "authentik.policies.reputation",
     "authentik.policies",
@@ -152,6 +153,7 @@
         "FlowDesignationEnum": "authentik.flows.models.FlowDesignation",
         "FlowLayoutEnum": "authentik.flows.models.FlowLayout",
         "PolicyEngineMode": "authentik.policies.models.PolicyEngineMode",
+        "GeoIPPolicyMode": "authentik.policies.geoip.models.GeoIPPolicyMode",
         "ProxyMode": "authentik.providers.proxy.models.ProxyMode",
         "PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
         "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 81a199907479..f1f8d927ccb8 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -448,6 +448,43 @@
                             }
                         }
                     },
+                    {
+                        "type": "object",
+                        "required": [
+                            "model",
+                            "identifiers"
+                        ],
+                        "properties": {
+                            "model": {
+                                "const": "authentik_policies_geoip.geoippolicy"
+                            },
+                            "id": {
+                                "type": "string"
+                            },
+                            "state": {
+                                "type": "string",
+                                "enum": [
+                                    "absent",
+                                    "present",
+                                    "created",
+                                    "must_created"
+                                ],
+                                "default": "present"
+                            },
+                            "conditions": {
+                                "type": "array",
+                                "items": {
+                                    "type": "boolean"
+                                }
+                            },
+                            "attrs": {
+                                "$ref": "#/$defs/model_authentik_policies_geoip.geoippolicy"
+                            },
+                            "identifiers": {
+                                "$ref": "#/$defs/model_authentik_policies_geoip.geoippolicy"
+                            }
+                        }
+                    },
                     {
                         "type": "object",
                         "required": [
@@ -3444,6 +3481,7 @@
                         "authentik.policies.event_matcher",
                         "authentik.policies.expiry",
                         "authentik.policies.expression",
+                        "authentik.policies.geoip",
                         "authentik.policies.password",
                         "authentik.policies.reputation",
                         "authentik.policies",
@@ -3512,6 +3550,7 @@
                         "authentik_policies_event_matcher.eventmatcherpolicy",
                         "authentik_policies_expiry.passwordexpirypolicy",
                         "authentik_policies_expression.expressionpolicy",
+                        "authentik_policies_geoip.geoippolicy",
                         "authentik_policies_password.passwordpolicy",
                         "authentik_policies_reputation.reputationpolicy",
                         "authentik_policies.policybinding",
@@ -3635,6 +3674,307 @@
             },
             "required": []
         },
+        "model_authentik_policies_geoip.geoippolicy": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string",
+                    "minLength": 1,
+                    "title": "Name"
+                },
+                "execution_logging": {
+                    "type": "boolean",
+                    "title": "Execution logging",
+                    "description": "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
+                },
+                "asn_mode": {
+                    "type": "string",
+                    "enum": [
+                        "allow",
+                        "block"
+                    ],
+                    "title": "Asn mode"
+                },
+                "asn_list": {
+                    "type": "array",
+                    "items": {
+                        "type": "integer",
+                        "minimum": -2147483648,
+                        "maximum": 2147483647,
+                        "title": "Asn list"
+                    },
+                    "title": "Asn list"
+                },
+                "country_mode": {
+                    "type": "string",
+                    "enum": [
+                        "allow",
+                        "block"
+                    ],
+                    "title": "Country mode"
+                },
+                "country_list": {
+                    "type": "array",
+                    "items": {
+                        "type": "string",
+                        "enum": [
+                            "AF",
+                            "AX",
+                            "AL",
+                            "DZ",
+                            "AS",
+                            "AD",
+                            "AO",
+                            "AI",
+                            "AQ",
+                            "AG",
+                            "AR",
+                            "AM",
+                            "AW",
+                            "AU",
+                            "AT",
+                            "AZ",
+                            "BS",
+                            "BH",
+                            "BD",
+                            "BB",
+                            "BY",
+                            "BE",
+                            "BZ",
+                            "BJ",
+                            "BM",
+                            "BT",
+                            "BO",
+                            "BA",
+                            "BW",
+                            "BV",
+                            "BR",
+                            "IO",
+                            "VG",
+                            "BN",
+                            "BG",
+                            "BF",
+                            "BI",
+                            "KH",
+                            "CM",
+                            "CA",
+                            "CV",
+                            "BQ",
+                            "KY",
+                            "CF",
+                            "TD",
+                            "CL",
+                            "CN",
+                            "CX",
+                            "CC",
+                            "CO",
+                            "KM",
+                            "CG",
+                            "CD",
+                            "CK",
+                            "CR",
+                            "CI",
+                            "HR",
+                            "CU",
+                            "CW",
+                            "CY",
+                            "CZ",
+                            "DK",
+                            "DJ",
+                            "DM",
+                            "DO",
+                            "EC",
+                            "EG",
+                            "SV",
+                            "GQ",
+                            "ER",
+                            "EE",
+                            "SZ",
+                            "ET",
+                            "FK",
+                            "FO",
+                            "FJ",
+                            "FI",
+                            "FR",
+                            "GF",
+                            "PF",
+                            "TF",
+                            "GA",
+                            "GM",
+                            "GE",
+                            "DE",
+                            "GH",
+                            "GI",
+                            "GR",
+                            "GL",
+                            "GD",
+                            "GP",
+                            "GU",
+                            "GT",
+                            "GG",
+                            "GN",
+                            "GW",
+                            "GY",
+                            "HT",
+                            "HM",
+                            "HN",
+                            "HK",
+                            "HU",
+                            "IS",
+                            "IN",
+                            "ID",
+                            "IR",
+                            "IQ",
+                            "IE",
+                            "IM",
+                            "IL",
+                            "IT",
+                            "JM",
+                            "JP",
+                            "JE",
+                            "JO",
+                            "KZ",
+                            "KE",
+                            "KI",
+                            "KW",
+                            "KG",
+                            "LA",
+                            "LV",
+                            "LB",
+                            "LS",
+                            "LR",
+                            "LY",
+                            "LI",
+                            "LT",
+                            "LU",
+                            "MO",
+                            "MG",
+                            "MW",
+                            "MY",
+                            "MV",
+                            "ML",
+                            "MT",
+                            "MH",
+                            "MQ",
+                            "MR",
+                            "MU",
+                            "YT",
+                            "MX",
+                            "FM",
+                            "MD",
+                            "MC",
+                            "MN",
+                            "ME",
+                            "MS",
+                            "MA",
+                            "MZ",
+                            "MM",
+                            "NA",
+                            "NR",
+                            "NP",
+                            "NL",
+                            "NC",
+                            "NZ",
+                            "NI",
+                            "NE",
+                            "NG",
+                            "NU",
+                            "NF",
+                            "KP",
+                            "MK",
+                            "MP",
+                            "NO",
+                            "OM",
+                            "PK",
+                            "PW",
+                            "PS",
+                            "PA",
+                            "PG",
+                            "PY",
+                            "PE",
+                            "PH",
+                            "PN",
+                            "PL",
+                            "PT",
+                            "PR",
+                            "QA",
+                            "RE",
+                            "RO",
+                            "RU",
+                            "RW",
+                            "WS",
+                            "SM",
+                            "ST",
+                            "SA",
+                            "SN",
+                            "RS",
+                            "SC",
+                            "SL",
+                            "SG",
+                            "SX",
+                            "SK",
+                            "SI",
+                            "SB",
+                            "SO",
+                            "ZA",
+                            "GS",
+                            "KR",
+                            "SS",
+                            "ES",
+                            "LK",
+                            "BL",
+                            "SH",
+                            "KN",
+                            "LC",
+                            "MF",
+                            "PM",
+                            "VC",
+                            "SD",
+                            "SR",
+                            "SJ",
+                            "SE",
+                            "CH",
+                            "SY",
+                            "TW",
+                            "TJ",
+                            "TZ",
+                            "TH",
+                            "TL",
+                            "TG",
+                            "TK",
+                            "TO",
+                            "TT",
+                            "TN",
+                            "TR",
+                            "TM",
+                            "TC",
+                            "TV",
+                            "UM",
+                            "VI",
+                            "UG",
+                            "UA",
+                            "AE",
+                            "GB",
+                            "US",
+                            "UY",
+                            "UZ",
+                            "VU",
+                            "VA",
+                            "VE",
+                            "VN",
+                            "WF",
+                            "EH",
+                            "YE",
+                            "ZM",
+                            "ZW"
+                        ],
+                        "title": "Country list"
+                    },
+                    "title": "Country list"
+                }
+            },
+            "required": []
+        },
         "model_authentik_policies_password.passwordpolicy": {
             "type": "object",
             "properties": {
diff --git a/schema.yml b/schema.yml
index add5a39a92ba..40df9f67ab3e 100644
--- a/schema.yml
+++ b/schema.yml
@@ -12135,6 +12135,275 @@ paths:
               schema:
                 $ref: '#/components/schemas/GenericError'
           description: ''
+  /policies/geoip/:
+    get:
+      operationId: policies_geoip_list
+      description: GeoIP Viewset
+      parameters:
+      - in: query
+        name: name
+        schema:
+          type: string
+      - name: ordering
+        required: false
+        in: query
+        description: Which field to use when ordering the results.
+        schema:
+          type: string
+      - name: page
+        required: false
+        in: query
+        description: A page number within the paginated result set.
+        schema:
+          type: integer
+      - name: page_size
+        required: false
+        in: query
+        description: Number of results to return per page.
+        schema:
+          type: integer
+      - name: search
+        required: false
+        in: query
+        description: A search term.
+        schema:
+          type: string
+      tags:
+      - policies
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PaginatedGeoIPPolicyList'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    post:
+      operationId: policies_geoip_create
+      description: GeoIP Viewset
+      tags:
+      - policies
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/GeoIPPolicyRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '201':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeoIPPolicy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /policies/geoip/{policy_uuid}/:
+    get:
+      operationId: policies_geoip_retrieve
+      description: GeoIP Viewset
+      parameters:
+      - in: path
+        name: policy_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this GeoIP Policy.
+        required: true
+      tags:
+      - policies
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeoIPPolicy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    put:
+      operationId: policies_geoip_update
+      description: GeoIP Viewset
+      parameters:
+      - in: path
+        name: policy_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this GeoIP Policy.
+        required: true
+      tags:
+      - policies
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/GeoIPPolicyRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeoIPPolicy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    patch:
+      operationId: policies_geoip_partial_update
+      description: GeoIP Viewset
+      parameters:
+      - in: path
+        name: policy_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this GeoIP Policy.
+        required: true
+      tags:
+      - policies
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PatchedGeoIPPolicyRequest'
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GeoIPPolicy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    delete:
+      operationId: policies_geoip_destroy
+      description: GeoIP Viewset
+      parameters:
+      - in: path
+        name: policy_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this GeoIP Policy.
+        required: true
+      tags:
+      - policies
+      security:
+      - authentik: []
+      responses:
+        '204':
+          description: No response body
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /policies/geoip/{policy_uuid}/used_by/:
+    get:
+      operationId: policies_geoip_used_by_list
+      description: Get a list of all objects that use this object
+      parameters:
+      - in: path
+        name: policy_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this GeoIP Policy.
+        required: true
+      tags:
+      - policies
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/UsedBy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
   /policies/password/:
     get:
       operationId: policies_password_list
@@ -20788,6 +21057,7 @@ paths:
           - authentik_policies_event_matcher.eventmatcherpolicy
           - authentik_policies_expiry.passwordexpirypolicy
           - authentik_policies_expression.expressionpolicy
+          - authentik_policies_geoip.geoippolicy
           - authentik_policies_password.passwordpolicy
           - authentik_policies_reputation.reputationpolicy
           - authentik_providers_google_workspace.googleworkspaceprovider
@@ -21007,6 +21277,7 @@ paths:
           - authentik_policies_event_matcher.eventmatcherpolicy
           - authentik_policies_expiry.passwordexpirypolicy
           - authentik_policies_expression.expressionpolicy
+          - authentik_policies_geoip.geoippolicy
           - authentik_policies_password.passwordpolicy
           - authentik_policies_reputation.reputationpolicy
           - authentik_providers_google_workspace.googleworkspaceprovider
@@ -32895,6 +33166,7 @@ components:
       - authentik.policies.event_matcher
       - authentik.policies.expiry
       - authentik.policies.expression
+      - authentik.policies.geoip
       - authentik.policies.password
       - authentik.policies.reputation
       - authentik.policies
@@ -34956,6 +35228,258 @@ components:
       required:
       - x_cord
       - y_cord
+    CountryListEnum:
+      enum:
+      - AF
+      - AX
+      - AL
+      - DZ
+      - AS
+      - AD
+      - AO
+      - AI
+      - AQ
+      - AG
+      - AR
+      - AM
+      - AW
+      - AU
+      - AT
+      - AZ
+      - BS
+      - BH
+      - BD
+      - BB
+      - BY
+      - BE
+      - BZ
+      - BJ
+      - BM
+      - BT
+      - BO
+      - BA
+      - BW
+      - BV
+      - BR
+      - IO
+      - VG
+      - BN
+      - BG
+      - BF
+      - BI
+      - KH
+      - CM
+      - CA
+      - CV
+      - BQ
+      - KY
+      - CF
+      - TD
+      - CL
+      - CN
+      - CX
+      - CC
+      - CO
+      - KM
+      - CG
+      - CD
+      - CK
+      - CR
+      - CI
+      - HR
+      - CU
+      - CW
+      - CY
+      - CZ
+      - DK
+      - DJ
+      - DM
+      - DO
+      - EC
+      - EG
+      - SV
+      - GQ
+      - ER
+      - EE
+      - SZ
+      - ET
+      - FK
+      - FO
+      - FJ
+      - FI
+      - FR
+      - GF
+      - PF
+      - TF
+      - GA
+      - GM
+      - GE
+      - DE
+      - GH
+      - GI
+      - GR
+      - GL
+      - GD
+      - GP
+      - GU
+      - GT
+      - GG
+      - GN
+      - GW
+      - GY
+      - HT
+      - HM
+      - HN
+      - HK
+      - HU
+      - IS
+      - IN
+      - ID
+      - IR
+      - IQ
+      - IE
+      - IM
+      - IL
+      - IT
+      - JM
+      - JP
+      - JE
+      - JO
+      - KZ
+      - KE
+      - KI
+      - KW
+      - KG
+      - LA
+      - LV
+      - LB
+      - LS
+      - LR
+      - LY
+      - LI
+      - LT
+      - LU
+      - MO
+      - MG
+      - MW
+      - MY
+      - MV
+      - ML
+      - MT
+      - MH
+      - MQ
+      - MR
+      - MU
+      - YT
+      - MX
+      - FM
+      - MD
+      - MC
+      - MN
+      - ME
+      - MS
+      - MA
+      - MZ
+      - MM
+      - NA
+      - NR
+      - NP
+      - NL
+      - NC
+      - NZ
+      - NI
+      - NE
+      - NG
+      - NU
+      - NF
+      - KP
+      - MK
+      - MP
+      - 'NO'
+      - OM
+      - PK
+      - PW
+      - PS
+      - PA
+      - PG
+      - PY
+      - PE
+      - PH
+      - PN
+      - PL
+      - PT
+      - PR
+      - QA
+      - RE
+      - RO
+      - RU
+      - RW
+      - WS
+      - SM
+      - ST
+      - SA
+      - SN
+      - RS
+      - SC
+      - SL
+      - SG
+      - SX
+      - SK
+      - SI
+      - SB
+      - SO
+      - ZA
+      - GS
+      - KR
+      - SS
+      - ES
+      - LK
+      - BL
+      - SH
+      - KN
+      - LC
+      - MF
+      - PM
+      - VC
+      - SD
+      - SR
+      - SJ
+      - SE
+      - CH
+      - SY
+      - TW
+      - TJ
+      - TZ
+      - TH
+      - TL
+      - TG
+      - TK
+      - TO
+      - TT
+      - TN
+      - TR
+      - TM
+      - TC
+      - TV
+      - UM
+      - VI
+      - UG
+      - UA
+      - AE
+      - GB
+      - US
+      - UY
+      - UZ
+      - VU
+      - VA
+      - VE
+      - VN
+      - WF
+      - EH
+      - YE
+      - ZM
+      - ZW
+      type: string
     CurrentBrand:
       type: object
       description: Partial brand information for styling
@@ -36651,6 +37175,99 @@ components:
           type: string
       required:
       - detail
+    GeoIPPolicy:
+      type: object
+      description: GeoIP Policy Serializer
+      properties:
+        pk:
+          type: string
+          format: uuid
+          readOnly: true
+          title: Policy uuid
+        name:
+          type: string
+        execution_logging:
+          type: boolean
+          description: When this option is enabled, all executions of this policy
+            will be logged. By default, only execution errors are logged.
+        component:
+          type: string
+          description: Get object component so that we know how to edit the object
+          readOnly: true
+        verbose_name:
+          type: string
+          description: Return object's verbose_name
+          readOnly: true
+        verbose_name_plural:
+          type: string
+          description: Return object's plural verbose_name
+          readOnly: true
+        meta_model_name:
+          type: string
+          description: Return internal model name
+          readOnly: true
+        bound_to:
+          type: integer
+          description: Return objects policy is bound to
+          readOnly: true
+        asn_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        asn_list:
+          type: array
+          items:
+            type: integer
+            maximum: 2147483647
+            minimum: -2147483648
+        country_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        country_list:
+          type: array
+          items:
+            $ref: '#/components/schemas/CountryListEnum'
+      required:
+      - asn_mode
+      - bound_to
+      - component
+      - country_mode
+      - meta_model_name
+      - name
+      - pk
+      - verbose_name
+      - verbose_name_plural
+    GeoIPPolicyMode:
+      enum:
+      - allow
+      - block
+      type: string
+    GeoIPPolicyRequest:
+      type: object
+      description: GeoIP Policy Serializer
+      properties:
+        name:
+          type: string
+          minLength: 1
+        execution_logging:
+          type: boolean
+          description: When this option is enabled, all executions of this policy
+            will be logged. By default, only execution errors are logged.
+        asn_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        asn_list:
+          type: array
+          items:
+            type: integer
+            maximum: 2147483647
+            minimum: -2147483648
+        country_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        country_list:
+          type: array
+          items:
+            $ref: '#/components/schemas/CountryListEnum'
+      required:
+      - asn_mode
+      - country_mode
+      - name
     GeoipBindingEnum:
       enum:
       - no_binding
@@ -38624,6 +39241,7 @@ components:
       - authentik_policies_event_matcher.eventmatcherpolicy
       - authentik_policies_expiry.passwordexpirypolicy
       - authentik_policies_expression.expressionpolicy
+      - authentik_policies_geoip.geoippolicy
       - authentik_policies_password.passwordpolicy
       - authentik_policies_reputation.reputationpolicy
       - authentik_policies.policybinding
@@ -39995,6 +40613,18 @@ components:
       required:
       - pagination
       - results
+    PaginatedGeoIPPolicyList:
+      type: object
+      properties:
+        pagination:
+          $ref: '#/components/schemas/Pagination'
+        results:
+          type: array
+          items:
+            $ref: '#/components/schemas/GeoIPPolicy'
+      required:
+      - pagination
+      - results
     PaginatedGoogleWorkspaceProviderGroupList:
       type: object
       properties:
@@ -42047,6 +42677,31 @@ components:
             to a challenge. RETRY returns the error message and a similar challenge
             to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT
             restarts the flow while keeping the current context.
+    PatchedGeoIPPolicyRequest:
+      type: object
+      description: GeoIP Policy Serializer
+      properties:
+        name:
+          type: string
+          minLength: 1
+        execution_logging:
+          type: boolean
+          description: When this option is enabled, all executions of this policy
+            will be logged. By default, only execution errors are logged.
+        asn_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        asn_list:
+          type: array
+          items:
+            type: integer
+            maximum: 2147483647
+            minimum: -2147483648
+        country_mode:
+          $ref: '#/components/schemas/GeoIPPolicyMode'
+        country_list:
+          type: array
+          items:
+            $ref: '#/components/schemas/CountryListEnum'
     PatchedGoogleWorkspaceProviderMappingRequest:
       type: object
       description: GoogleWorkspaceProviderMapping Serializer
diff --git a/web/src/admin/policies/PolicyWizard.ts b/web/src/admin/policies/PolicyWizard.ts
index c66e9532495b..b17ccc873d04 100644
--- a/web/src/admin/policies/PolicyWizard.ts
+++ b/web/src/admin/policies/PolicyWizard.ts
@@ -3,6 +3,7 @@ import "@goauthentik/admin/policies/dummy/DummyPolicyForm";
 import "@goauthentik/admin/policies/event_matcher/EventMatcherPolicyForm";
 import "@goauthentik/admin/policies/expiry/ExpiryPolicyForm";
 import "@goauthentik/admin/policies/expression/ExpressionPolicyForm";
+import "@goauthentik/admin/policies/geoip/GeoIPPolicyForm";
 import "@goauthentik/admin/policies/password/PasswordPolicyForm";
 import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
 import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
diff --git a/web/src/admin/policies/geoip/GeoIPPolicyForm.ts b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts
new file mode 100644
index 000000000000..9c40484183af
--- /dev/null
+++ b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts
@@ -0,0 +1,170 @@
+import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { first } from "@goauthentik/common/utils";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/SearchSelect";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { App, GeoIPPolicy, PoliciesApi, TypeCreate } from "@goauthentik/api";
+
+@customElement("ak-policy-geoip-form")
+export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
+    loadInstance(pk: string): Promise<GeoIPPolicy> {
+        return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipRetrieve({
+            policyUuid: pk,
+        });
+    }
+
+    async send(data: GeoIPPolicy): Promise<GeoIPPolicy> {
+        if (data.asnMode?.toString() === "") {
+            data.asnMode = "block";
+        }
+
+        if (data.asnList?.toString() === "") {
+            data.asnList = [];
+        } else {
+            data.asnList = data.asnList.split(",").map(Number);
+        }
+
+        if (data.countryMode?.toString() === "") {
+            data.countryMode = "block";
+        }
+
+        if (data.countryList?.toString() === "") {
+            data.countryList = [];
+        } else {
+            data.countryList = data.countryList.split(",");
+        }
+
+        if (this.instance) {
+            return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipUpdate({
+                policyUuid: this.instance.pk || "",
+                geoIPPolicyRequest: data,
+            });
+        } else {
+            return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipCreate({
+                geoIPPolicyRequest: data,
+            });
+        }
+    }
+
+    renderForm(): TemplateResult {
+        return html` <span>
+                ${msg(
+                    "Ensure the user satisfies requirements of geography or network topology, based on IP address.",
+                )}
+            </span>
+            <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
+                <input
+                    type="text"
+                    value="${ifDefined(this.instance?.name || "")}"
+                    class="pf-c-form-control"
+                    required
+                />
+            </ak-form-element-horizontal>
+            <ak-form-element-horizontal name="executionLogging">
+                <label class="pf-c-switch">
+                    <input
+                        class="pf-c-switch__input"
+                        type="checkbox"
+                        ?checked=${first(this.instance?.executionLogging, false)}
+                    />
+                    <span class="pf-c-switch__toggle">
+                        <span class="pf-c-switch__toggle-icon">
+                            <i class="fas fa-check" aria-hidden="true"></i>
+                        </span>
+                    </span>
+                    <span class="pf-c-switch__label">${msg("Execution logging")}</span>
+                </label>
+                <p class="pf-c-form__helper-text">
+                    ${msg(
+                        "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
+                    )}
+                </p>
+            </ak-form-element-horizontal>
+            <ak-form-group .expanded=${true}>
+                <span slot="header"> ${msg("Policy-specific settings")} </span>
+                <div slot="body" class="pf-c-form">
+                    <ak-form-element-horizontal label=${msg("ASN Mode")} name="asnMode">
+                        <ak-search-select
+                            .fetchObjects=${async () => {
+                                return [
+                                    { name: "allow", label: "Allow" },
+                                    { name: "block", label: "Block" },
+                                ];
+                            }}
+                            .renderElement=${(item: TypeCreate): string => {
+                                return item.label;
+                            }}
+                            .value=${(item: TypeCreate | undefined): string | undefined => {
+                                return item.name;
+                            }}
+                            .selected=${(item: TypeCreate): boolean => {
+                                return this.instance?.asnMode === item.name;
+                            }}
+                        >
+                        </ak-search-select>
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "Block or only allow connections from IPs from the provided autonomous systems.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal label=${msg("ASN List")} name="asnList">
+                        <input
+                            type="text"
+                            value="${ifDefined(this.instance?.asnList || "")}"
+                            class="pf-c-form-control"
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "List of autonomous system numbers. Comma separated. E.g. 13335,15169,20940",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal label=${msg("Country Mode")} name="countryMode">
+                        <ak-search-select
+                            .fetchObjects=${async () => {
+                                return [
+                                    { name: "allow", label: "Allow" },
+                                    { name: "block", label: "Block" },
+                                ];
+                            }}
+                            .renderElement=${(item: App): string => {
+                                return item.label;
+                            }}
+                            .value=${(item: App | undefined): string | undefined => {
+                                return item.name;
+                            }}
+                            .selected=${(item: App): boolean => {
+                                return this.instance?.countryMode === item.name;
+                            }}
+                        >
+                        </ak-search-select>
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "Block or only allow connections from IPs from the provided countries.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal label=${msg("Country List")} name="countryList">
+                        <input
+                            type="text"
+                            value="${ifDefined(this.instance?.countryList || "")}"
+                            class="pf-c-form-control"
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "List of ISO-3166-1 alpha-2 country codes. Comma separated. E.g. NO,DK,SE",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                </div>
+            </ak-form-group>`;
+    }
+}


Code review comments:
diff_hunk: @@ -0,0 +1,170 @@
+import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { first } from "@goauthentik/common/utils";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/SearchSelect";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { App, GeoIPPolicy, PoliciesApi, TypeCreate } from "@goauthentik/api";
+
+@customElement("ak-policy-geoip-form")
+export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
+    loadInstance(pk: string): Promise<GeoIPPolicy> {
+        return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipRetrieve({
+            policyUuid: pk,
+        });
+    }
+
+    async send(data: GeoIPPolicy): Promise<GeoIPPolicy> {
+        if (data.asnMode?.toString() === "") {
+            data.asnMode = "block";
+        }
+
+        if (data.asnList?.toString() === "") {
+            data.asnList = [];
+        } else {
+            data.asnList = data.asnList.split(",").map(Number);
+        }
+
+        if (data.countryMode?.toString() === "") {
+            data.countryMode = "block";
+        }
+
+        if (data.countryList?.toString() === "") {
+            data.countryList = [];
+        } else {
+            data.countryList = data.countryList.split(",");
+        }
+
+        if (this.instance) {
+            return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipUpdate({
+                policyUuid: this.instance.pk || "",
+                geoIPPolicyRequest: data,
+            });
+        } else {
+            return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipCreate({
+                geoIPPolicyRequest: data,
+            });
+        }
+    }
+
+    renderForm(): TemplateResult {
+        return html` <span>
+                ${msg(
+                    "Ensure the user satisfies requirements of geography or network topology, based on IP address.",
+                )}
+            </span>
+            <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
+                <input
+                    type="text"
+                    value="${ifDefined(this.instance?.name || "")}"
+                    class="pf-c-form-control"
+                    required
+                />
+            </ak-form-element-horizontal>
+            <ak-form-element-horizontal name="executionLogging">
+                <label class="pf-c-switch">
+                    <input
+                        class="pf-c-switch__input"
+                        type="checkbox"
+                        ?checked=${first(this.instance?.executionLogging, false)}
+                    />
+                    <span class="pf-c-switch__toggle">
+                        <span class="pf-c-switch__toggle-icon">
+                            <i class="fas fa-check" aria-hidden="true"></i>
+                        </span>
+                    </span>
+                    <span class="pf-c-switch__label">${msg("Execution logging")}</span>
+                </label>
+                <p class="pf-c-form__helper-text">
+                    ${msg(
+                        "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
+                    )}
+                </p>
+            </ak-form-element-horizontal>
+            <ak-form-group .expanded=${true}>
+                <span slot="header"> ${msg("Policy-specific settings")} </span>
+                <div slot="body" class="pf-c-form">
+                    <ak-form-element-horizontal label=${msg("ASN Mode")} name="asnMode">
+                        <ak-search-select
+                            .fetchObjects=${async () => {
+                                return [
+                                    { name: "allow", label: "Allow" },
path: web/src/admin/policies/geoip/GeoIPPolicyForm.ts
text: @user1:
the value for this (regardless of which UI component is used) should use the enum from the API instead of the raw string

diff_hunk: @@ -0,0 +1,170 @@
+import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { first } from "@goauthentik/common/utils";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/SearchSelect";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { App, GeoIPPolicy, PoliciesApi, TypeCreate } from "@goauthentik/api";
+
+@customElement("ak-policy-geoip-form")
+export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
+    loadInstance(pk: string): Promise<GeoIPPolicy> {
+        return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipRetrieve({
+            policyUuid: pk,
+        });
+    }
+
+    async send(data: GeoIPPolicy): Promise<GeoIPPolicy> {
+        if (data.asnMode?.toString() === "") {
path: web/src/admin/policies/geoip/GeoIPPolicyForm.ts
text: @user1:
This is somewhat of a downside of the generated API client, but this value here should never be undefined so this if shouldn't be required

@author:
Combined with the check below (which is still needed because `"".split(",")` is non-empty)

diff_hunk: @@ -0,0 +1,81 @@
+"""GeoIP policy"""
+
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+
+from authentik.policies.exceptions import PolicyException
+from authentik.policies.geoip.countries import COUNTRIES
+from authentik.policies.geoip.exceptions import GeoIPNotFoundException
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class GeoIPPolicyMode(models.TextChoices):
path: authentik/policies/geoip/models.py
text: @user1:
Usually we don't have this as a setting directly on the policy but instead use the `negate` flag on the policy binding to inverse the effects. For this case I'm a bit on the fence as you can do two different things in the same policy with it

@author:
Oh, I didn't notice that flag! That makes everything so much simpler. I'm not on the fence about it at all :smile: 

Then there's no need for `*_mode` and this model will just have 2 fields: `asn_list` and `country_list`. Same as Event Matcher Policy for least astonishment:

> If any of the configured values match, the policy passes.