Skip to content

Product documentation

Restrict logins by country (geo-blocking)

Configure a per-tenant country allowlist that gates Thoryn-managed credential logins — the three enforcement modes (off / warn / block), the customer-plane /api/v1/geo-policy API, the operator switches, and the self-lockout safeguards — grounded in the shipped code.

Restrict logins by country (geo-blocking)

Geo-blocking lets a tenant admin restrict where its users may sign in from: a per-tenant allowlist of ISO-3166-1 alpha-2 country codes, enforced at login. The country is resolved from the login IP by an in-process GeoIP dataset — there is no outbound call on the sign-in path.

Scope — what geo-blocking gates

Geo-blocking gates Thoryn-managed credential logins (users who authenticate against Thoryn's own identity-service). Logins federated to an enterprise IdP (Microsoft Entra ID, Okta, Google Workspace, …) authenticate at that IdP and are brokered by the hub; geo-blocking those is not in scope here — enforce location policy at the upstream IdP instead.

This page documents the configuration API (/api/v1/geo-policy) and the enforcement it drives. A console UI for the same workflow is planned as the thoryn-web half of the same story and is not yet shipped — until it lands, configure geo-blocking through the API below (or the thoryn CLI).

The three modes

The policy has a single mode plus the country allowlist:

ModeA login from a NON-allowlisted country is…Use it to
offAllowed. No geo constraint.Turn the control off (the default).
warnAllowed, but audited (login.geo_warning).Observe impact safely before enforcing — the recommended first step.
blockDenied, and audited (login.geo_blocked).Enforce the allowlist.

Two rules keep the control from silently locking a tenant out:

  • An empty allowlist is always inert. Whatever the configured mode, the effective mode is off when the allowlist is empty. The response echoes an effectiveMode field so a client can warn that a warn/block mode with no countries does nothing.
  • block with an empty allowlist is rejected (it would deny every login). The API returns 400 empty_block_allowlist.

Configure it — step by step

The API is reached through the public gateway at https://api.<env>.thoryn.org. Every call carries a tenant admin's bearer token; the tenant is taken from the token's tnt claim, so a caller only ever sees and writes its own tenant's policy. Reads need the tenant:geo.read scope; writes need tenant:geo.write.

  1. Read the current policy. An unconfigured tenant returns mode: off, an empty allowlist, and policyVersion: 0.

    curl -s https://api.stg.thoryn.org/api/v1/geo-policy \
      -H "Authorization: Bearer $TOKEN"
  2. Roll out in warn first. Set the allowlist and observe the login.geo_warning audit events for logins that would be blocked, without denying anyone.

    curl -s -X PUT https://api.stg.thoryn.org/api/v1/geo-policy \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"mode":"warn","allowedCountries":["NL","DE","US"]}'
  3. Switch to block once the warn-mode audit trail confirms your allowlist covers your real users.

    curl -s -X PUT https://api.stg.thoryn.org/api/v1/geo-policy \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"mode":"block","allowedCountries":["NL","DE","US"]}'
  4. Remove the constraint at any time — reset to no geo policy:

    curl -s -X DELETE https://api.stg.thoryn.org/api/v1/geo-policy \
      -H "Authorization: Bearer $TOKEN"

Country codes are normalised on write (trimmed, upper-cased, de-duplicated), so ["nl"," de "] stores as ["DE","NL"].

Endpoint reference

The full generated request/response schema is on the GeoPolicy API reference page.

MethodPathDescriptionScope
GET/api/v1/geo-policyRead the stored policy, its effective mode, and the supported modes. 200 always.tenant:geo.read
PUT/api/v1/geo-policyUpsert the mode + allowlist. Validated; 200 with the saved state.tenant:geo.write
DELETE/api/v1/geo-policyReset the tenant to no geo constraint. 204.tenant:geo.write

The GET/PUT response body carries mode, allowedCountries, effectiveMode, supportedModes, policyVersion, and updatedAt.

Configuration (operators)

The tenant-writable policy above rides on two platform switches that gate the whole control at deploy level (identity-service). They are not tenant-writable.

oauthy:
  identity:
    geo-blocking:
      # Master switch. true (default) → honour each tenant's stored policy.
      # false → the enforcer always allows (the control is inert fleet-wide) —
      # the break-glass if geo enforcement must be disabled without editing rows.
      enabled: true
      # What to do when the login IP cannot be resolved to a country (private /
      # loopback IP, or an unmapped public range). true (default) → fail OPEN
      # (allow) so a dataset gap or carrier-grade NAT is never a self-inflicted
      # lockout. false → a block-mode tenant denies a login of unknown location.
      fail-open-on-unresolved: true

Troubleshooting

SymptomCauseFix
400 invalid_modemode was not one of off / warn / block.Send a supported mode token.
400 invalid_country_code (extension field: allowedCountries)An entry was not a two-letter ISO-3166-1 alpha-2 code.Use alpha-2 codes (NL, DE), not names or alpha-3.
400 empty_block_allowlistmode: block with an empty allowlist — it would deny every login.Add at least one country, or use warn/off.
403 on a read/writeThe token lacks tenant:geo.read / tenant:geo.write.Grant the scope to the client (see below) and re-authenticate.
503 facade_not_configuredThe deployment has not mounted the projected identity-facade token.An operator concern — the chart mounts it; not reachable in local-dev without it.
502 upstream_identity_erroridentity-service was unreachable or errored.Transient; retry. Persistent → check identity-service health.
A block policy is not denying anyoneThe allowlist is empty, so effectiveMode is off; or the master switch is false; or the login country is unresolved and fail-open-on-unresolved is true.Check effectiveMode in the GET response and the operator switches.

Enabling the scopes

The tenant:geo.read / tenant:geo.write scopes are granted to the customer-plane clients (console BFF, thoryn CLI) by hub migration V101__add_geo_policy_scopes_to_customer_plane_clients.sql. A client must carry the scope for the hub to mint it into the token; requesting an un-granted scope fails sign-in with invalid_scope.

Security notes

  • Self-lockout is a real risk, and the config plane cannot fully prevent it. A tenant that sets block with an allowlist excluding its own admins' country can lock those admins out of their next credential login. The config API deliberately does not try to auto-detect and refuse that: a login's country is derived from the login IP at sign-in, not from the admin's token or this configuration request (which reaches product-api through the gateway, not from the admin's own client IP), so any config-plane country self-check would be unreliable by construction. Instead the risk is contained by rolling out in warn first, by the empty-allowlist / block-with-empty-allowlist safeguards above, and — as a last-resort break-glass — the operator master switch oauthy.identity.geo-blocking.enabled=false.
  • The control fails open on every uncertainty. Master switch off, no policy, empty allowlist, a store-read error, and (by default) an unresolved country all allow the login. Only an explicit block-mode policy with a resolved, non-allowlisted country denies. Geo-blocking is a hardening layer, never the platform's only defence.
  • Cross-tenant access is structural, not a check. The tenant is taken from the tnt claim and scoped into every query, so a caller cannot read or write another tenant's policy — there is no cross-tenant identifier to supply.
  • Error responses are sanitised. Validation detail strings echo only your own input; they never leak another tenant's stored countries or token contents.