Security

IP allowlist and session policy

Two workspace-level controls let security teams shrink the attack surface beyond authentication: an IP CIDR allowlist that gates every request before it…

Last updated

Overview

Two workspace-level controls let security teams shrink the attack surface beyond authentication: an IP CIDR allowlist that gates every request before it reaches the application (WC.7), and a session policy that enforces idle timeout plus per-action MFA recency (WC.8). Both are configured under Settings → Security and stored in Workspace.settings.

These controls layer on top of SAML/OIDC/MFA. A user who has passed federated login and TOTP still cannot reach the workspace if their source IP is not on the allowlist, and still cannot trigger a sensitive action if they have not re-verified MFA in the last N minutes.

IP allowlist (WC.7)

Workspace.settings['ip_allowlist']

The allowlist is a JSON list of CIDR strings stored at Workspace.settings['ip_allowlist']:

["10.0.0.0/8", "192.168.1.0/24", "203.0.113.42/32"]

An empty list (or missing key) means no restriction — every IP can reach the workspace, subject to authentication. A non-empty list means only these CIDRs can reach it; everything else gets a 403 forbidden from the middleware.

CIDR strings are parsed with Python's ipaddress.ip_network. Both IPv4 and IPv6 are supported. Use /32 for an exact host or /128 for an exact IPv6 host.

IPAllowlistMiddleware

IPAllowlistMiddleware runs early in the request pipeline (after RLSContextMiddleware resolves the workspace, before the view dispatch). For each request:

  1. Resolve the workspace (already done upstream).
  2. Read workspace.settings.get('ip_allowlist', []).
  3. If empty, allow.
  4. Extract the client IP (see "X-Forwarded-For" below).
  5. Check if any CIDR contains the IP. If yes, allow; otherwise return 403 with a JSON body {"detail": "Source IP not allowed for this workspace.", "code": "ip_not_allowlisted"}.

The middleware bypasses the allowlist for the workspace owner only when the request is from the workspace's break-glass path (/admin/breakglass/) — this prevents an owner from accidentally locking themselves out by misconfiguring the list.

X-Forwarded-For and SECURE_PROXY_SSL_HEADER

If Vigilo runs behind a reverse proxy (nginx, ELB, Cloudflare), the request socket IP is the proxy's IP, not the client's. The middleware reads the leftmost untrusted value from X-Forwarded-For only when SECURE_PROXY_SSL_HEADER is configured in Django settings, which signals "we trust this proxy header".

Critical correctness rule: never enable IP allowlisting without also setting SECURE_PROXY_SSL_HEADER and configuring the proxy to set X-Forwarded-For. Otherwise every client appears to come from the proxy's IP, and the allowlist either lets everyone in or blocks everyone out.

The recommended nginx config:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;

And in Django settings:

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

Session policy (WC.8)

Idle timeout

Workspace.settings['session_policy']['idle_timeout_minutes'] (default 60) controls how long a session can sit idle before being invalidated. Every request updates Session.last_activity_at; a request whose now() - last_activity_at > idle_timeout_minutes gets a 401, the cookie is cleared, and the user is sent back to login.

Set to 0 for no idle timeout (not recommended outside dev).

mfa_required_for_actions

Workspace.settings['session_policy']['mfa_required_for_actions'] is an allowlist of action keys that require MFA to have been verified recently. Default action keys include:

  • workspace.delete
  • workspace.rotate_signing_key
  • member.remove
  • cmek.rotate
  • integration.delete
  • scim_token.create
  • data_export.run
  • data_forget.run

A typical policy adds the highest-risk operations only. An over-broad list trains users to keep TOTP open in a tab, which defeats the security intent.

MFARequiredForAction permission class

A DRF permission class lives at vigilo_django.security.permissions.MFARequiredForAction. Apply it to ViewSet actions:

class WorkspaceViewSet(WorkspaceScopedMixin, ModelViewSet):
    @action(detail=True, methods=['delete'], permission_classes=[MFARequiredForAction('workspace.delete')])
    def destroy(self, request, ...):
        ...

The class checks two conditions:

  1. The action key is in workspace.settings['session_policy']['mfa_required_for_actions'].
  2. If yes, the session has a mfa_recent_at timestamp newer than mfa_recent_window_minutes (default 5 min).

If either check fails (the action requires MFA recency AND no recent verification), the response is 403 with body {"detail": "MFA verification required for this action.", "code": "mfa_required"} and an extra header WWW-MFA: required so the frontend can pop the TOTP dialog without parsing the body.

mark_mfa_recent helper

The helper vigilo_django.security.helpers.mark_mfa_recent(request) stamps mfa_recent_at on the session. It's called by:

  • POST /api/v1/auth/confirm-totp/ after successful code verification
  • POST /api/v1/auth/setup-totp/ and enable-totp/ (initial enrollment counts as recent verification)
  • Any custom view that triggers a re-auth flow

The frontend's MFA dialog calls confirm-totp and then retries the original action, which now passes the permission check.

Common workflows

1. Lock the workspace to an office VPN range

  1. Settings → Security → IP allowlist.
  2. Add 10.20.0.0/16 (your VPN range).
  3. Confirm SECURE_PROXY_SSL_HEADER is set on the Django host (deployment-team job).
  4. Save. New requests from outside the VPN get a 403.

2. Add a temporary IP for a remote engineer

  1. Same settings page; add 203.0.113.42/32 to the list.
  2. Use Remove on the same row when no longer needed.
  3. Every add/remove writes an audit log entry with the actor and timestamp.

3. Require MFA recency for cert downloads

  1. Settings → Security → Session policy → MFA required for actions.
  2. Add cert.download to the list.
  3. Save. Any cert download attempt now triggers a TOTP prompt unless the user verified in the last 5 minutes.

Adjust mfa_recent_window_minutes (in the same settings dict) if 5 minutes feels too aggressive — 15 is common.

4. Inspect why a request was blocked

  1. Audit log → Filter: action=session.ip_blocked. Each blocked request writes an entry with the source IP, the workspace, and the time.
  2. Cross-reference with the user's reported location. If legitimate, add to the allowlist.

Permissions

Action Role
Edit IP allowlist Owner
Edit session policy Owner
View blocked requests in audit log Admin / Owner
Confirm own MFA for action Any member with mfa_enabled

Troubleshooting

"Every user is locked out" after enabling the allowlist. Either the allowlist is empty (no CIDRs) which means deny-all in some misreadings, or SECURE_PROXY_SSL_HEADER is unset and every client appears as the proxy. Confirm both. The break-glass URL /admin/breakglass/ is your owner's escape hatch.

Idle timeout fires after 1 minute instead of 60. Check the configured value — it's in minutes, not seconds. The unit suffix on the input field is your friend.

TOTP dialog keeps appearing for every API call. The frontend isn't calling mark_mfa_recent after confirm-totp, or the mfa_recent_window_minutes is too short. Increase the window or fix the frontend retry to re-issue the original request with the same session cookie.

The WWW-MFA: required header isn't reaching the browser. A CDN or proxy is stripping non-standard headers. Whitelist headers starting with WWW- or X- in the proxy config.

A user inside the allowlist still gets blocked. Confirm the IP in the audit log entry matches what the user reports — they might be behind a NAT that allocates IPs outside the published range, or they're on a backup ISP. Add the actual IP, not the expected one.

Related articles