Skip to content

Commit 8cb690a

Browse files
authored
Merge pull request #1219 from NHSDigital/dtoss-12506-production-on-off-switch
SERVICE_ENABLED
2 parents ef637b1 + c4e18ed commit 8cb690a

8 files changed

Lines changed: 126 additions & 1 deletion

File tree

docs/infrastructure/environment-variables.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ This value [the SECRET_KEY setting] is the key to securing signed data – it is
173173

174174
To rotate: generate a new random key
175175

176+
## SERVICE_ENABLED
177+
178+
When set to False disables all URLs except those decorated with `service_enabled_exempt`, e.g. /sha and /healthcheck
179+
176180
## SSL_MODE
177181

178182
[SSL mode](https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION) of the client connection to the Postgres server. Set to "require" on deployed environments.

infrastructure/environments/prod/variables.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ BASIC_AUTH_ENABLED: True
33
CIS2_SERVER_METADATA_URL: https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/.well-known/openid-configuration
44
CSRF_TRUSTED_ORIGINS: 'https://manage-breast-screening.nhs.uk'
55
APPLICATIONINSIGHTS_IS_ENABLED: True
6+
SERVICE_ENABLED: True

manage_breast_screening/config/settings/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def list_env(key):
4242
# SECURE_SSL_REDIRECT is set to False because TLS termination is handled at the Azure Container Apps layer
4343
SECURE_SSL_REDIRECT = False
4444

45+
SERVICE_ENABLED = boolean_env("SERVICE_ENABLED", default=True)
46+
4547
# SECURITY WARNING: don't run with PERSONAS_ENABLED turned on in production!
4648
PERSONAS_ENABLED = boolean_env("PERSONAS_ENABLED", default=False)
4749

@@ -79,6 +81,7 @@ def list_env(key):
7981
"django.middleware.security.SecurityMiddleware",
8082
"whitenoise.middleware.WhiteNoiseMiddleware",
8183
"manage_breast_screening.core.middleware.exception_logging.CorrelationIdMiddleware",
84+
"manage_breast_screening.core.middleware.service_enabled.ServiceEnabledMiddleware",
8285
"manage_breast_screening.core.middleware.exception_logging.ExceptionLoggingMiddleware",
8386
"manage_breast_screening.core.middleware.robots.RobotsTagMiddleware",
8487
"manage_breast_screening.core.middleware.basic_auth.BasicAuthMiddleware",

manage_breast_screening/core/decorators.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
_basic_auth_exempt_views = set()
55
_current_provider_exempt_views = set()
6+
_service_enabled_exempt_views = set()
67

78

89
def basic_auth_exempt(view_func: Callable) -> Callable:
@@ -33,6 +34,20 @@ def is_current_provider_exempt(view_func: Callable) -> bool:
3334
return view_func_identifier(view_func) in _current_provider_exempt_views
3435

3536

37+
def service_enabled_exempt(view_func: Callable) -> Callable:
38+
"""Mark a view function as exempt from ServiceEnabledMiddleware.
39+
40+
Uses a registry approach that is decorator-order independent.
41+
"""
42+
_service_enabled_exempt_views.add(view_func_identifier(view_func))
43+
return view_func
44+
45+
46+
def is_service_enabled_exempt(view_func: Callable) -> bool:
47+
"""Check if a view function is exempt from ServiceEnabledMiddleware."""
48+
return view_func_identifier(view_func) in _service_enabled_exempt_views
49+
50+
3651
def view_func_identifier(view_func: Callable) -> str:
3752
if isinstance(view_func, partial):
3853
view_func = view_func.func
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% extends "layout-app.jinja" %}
2+
3+
{% block content %}
4+
<div class="nhsuk-grid-row">
5+
<div class="nhsuk-grid-column-two-thirds">
6+
<h1>Sorry, this service is unavailable</h1>
7+
<p>The service is unavailable due to planned maintenance.</p>
8+
</div>
9+
</div>
10+
{% endblock %}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Callable, Optional
2+
3+
from django.conf import settings
4+
from django.http import HttpResponse
5+
from django.shortcuts import render
6+
7+
from manage_breast_screening.core.decorators import is_service_enabled_exempt
8+
9+
10+
class ServiceEnabledMiddleware:
11+
"""Returns 503 for all non-exempt views when SERVICE_ENABLED=False.
12+
13+
Exempt views (healthcheck, sha, robots.txt) continue to respond normally
14+
so that Azure Container Apps health probes don't interpret the outage as a
15+
container failure and attempt to restart instances.
16+
"""
17+
18+
def __init__(self, get_response: Callable):
19+
self.get_response = get_response
20+
21+
def __call__(self, request):
22+
return self.get_response(request)
23+
24+
def process_view(
25+
self, request, view_func, view_args, view_kwargs
26+
) -> Optional[HttpResponse]:
27+
if getattr(settings, "SERVICE_ENABLED", True):
28+
return None
29+
30+
if is_service_enabled_exempt(view_func):
31+
return None
32+
33+
return render(request, "503.jinja", status=503)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from typing import Callable
2+
3+
import pytest
4+
from django.http import HttpResponse
5+
from django.test import RequestFactory
6+
7+
from manage_breast_screening.core.decorators import service_enabled_exempt
8+
from manage_breast_screening.core.middleware.service_enabled import (
9+
ServiceEnabledMiddleware,
10+
)
11+
12+
13+
def _make_middleware(get_response: Callable | None = None) -> ServiceEnabledMiddleware:
14+
return ServiceEnabledMiddleware(get_response or (lambda r: HttpResponse("OK")))
15+
16+
17+
@pytest.mark.django_db
18+
class TestServiceEnabledMiddleware:
19+
def test_service_enabled_allows_request(self, settings):
20+
settings.SERVICE_ENABLED = True
21+
request = RequestFactory().get("/")
22+
23+
mw = _make_middleware()
24+
assert mw.process_view(request, lambda r: None, (), {}) is None
25+
26+
def test_service_disabled_returns_503(self, settings):
27+
settings.SERVICE_ENABLED = False
28+
request = RequestFactory().get("/")
29+
30+
mw = _make_middleware()
31+
resp = mw.process_view(request, lambda r: None, (), {})
32+
assert resp is not None
33+
assert resp.status_code == 503
34+
assert b"Sorry, this service is unavailable" in resp.content
35+
36+
def test_service_disabled_exempt_view_allows_request(self, settings):
37+
settings.SERVICE_ENABLED = False
38+
request = RequestFactory().get("/healthcheck")
39+
40+
def view(_request):
41+
return HttpResponse("OK")
42+
43+
service_enabled_exempt(view)
44+
45+
mw = _make_middleware()
46+
assert mw.process_view(request, view, (), {}) is None
47+
48+
def test_missing_setting_defaults_to_enabled(self, settings):
49+
del settings.SERVICE_ENABLED
50+
request = RequestFactory().get("/")
51+
52+
mw = _make_middleware()
53+
assert mw.process_view(request, lambda r: None, (), {}) is None

manage_breast_screening/core/urls.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
from django.views.decorators.http import require_GET
2323
from django.views.generic.base import RedirectView
2424

25-
from manage_breast_screening.core.decorators import basic_auth_exempt
25+
from manage_breast_screening.core.decorators import (
26+
basic_auth_exempt,
27+
service_enabled_exempt,
28+
)
2629

2730
from ..clinics import views as clinic_views
2831
from .admin import admin_site
@@ -35,13 +38,15 @@
3538

3639
@require_GET
3740
@basic_auth_exempt
41+
@service_enabled_exempt
3842
@login_not_required
3943
def sha_view(request):
4044
return HttpResponse(settings.COMMIT_SHA)
4145

4246

4347
@require_GET
4448
@basic_auth_exempt
49+
@service_enabled_exempt
4550
@login_not_required
4651
def health_check(request):
4752
return HttpResponse("OK")
@@ -54,6 +59,7 @@ def health_check(request):
5459

5560
@require_GET
5661
@basic_auth_exempt
62+
@service_enabled_exempt
5763
@login_not_required
5864
def robots_txt(request):
5965
return HttpResponse(ROBOTS_TXT, content_type="text/plain")

0 commit comments

Comments
 (0)