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:
- Resolve the workspace (already done upstream).
- Read
workspace.settings.get('ip_allowlist', []). - If empty, allow.
- Extract the client IP (see "X-Forwarded-For" below).
- Check if any CIDR contains the IP. If yes, allow; otherwise return
403with 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.deleteworkspace.rotate_signing_keymember.removecmek.rotateintegration.deletescim_token.createdata_export.rundata_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:
- The action key is in
workspace.settings['session_policy']['mfa_required_for_actions']. - If yes, the session has a
mfa_recent_attimestamp newer thanmfa_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 verificationPOST /api/v1/auth/setup-totp/andenable-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
- Settings → Security → IP allowlist.
- Add
10.20.0.0/16(your VPN range). - Confirm
SECURE_PROXY_SSL_HEADERis set on the Django host (deployment-team job). - Save. New requests from outside the VPN get a
403.
2. Add a temporary IP for a remote engineer
- Same settings page; add
203.0.113.42/32to the list. - Use Remove on the same row when no longer needed.
- Every add/remove writes an audit log entry with the actor and timestamp.
3. Require MFA recency for cert downloads
- Settings → Security → Session policy → MFA required for actions.
- Add
cert.downloadto the list. - 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
- Audit log → Filter: action=session.ip_blocked. Each blocked request writes an entry with the source IP, the workspace, and the time.
- 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
- RBAC and tenancy — what an authenticated, allowed user can do.
- SAML, OIDC, and MFA — how the MFA factor gets established.
- CMEK and encryption — secret-handling layer that pairs with this control.