¶ DSPT Standard 9: Firewalls & Boundary Protection — Assessment
Category: DSPT Evidence Checklist
Scope: gp_booking_app (/var/www/html/gp_booking_app)
Date: 14 May 2026
Context: Multi-tenant GP booking platform hosted on Fasthosts NGCS VPS (88.208.212.211)
Standard 9: IT Protection
[MANDATORY] Firewalls and boundary protection are in place
Evidence: IT supplier statement confirming firewall configuration, or network diagram
In a multi-tenant SaaS context, "boundaries" encompass both network-level firewalls and application-level logical boundaries that segregate each GP surgery's data from others.
| Item |
Detail |
| Provider |
Fasthosts Internet Ltd (UK) — NGCS VPS |
| IP |
88.208.212.211 |
| Hypervisor |
Standard VPS — hypervisor isolates from other customers |
| Cloud firewall |
Not confirmed — if Fasthosts offers one, check control panel |
| Check |
Result |
| iptables INPUT policy |
ACCEPT — no restrictive inbound rules |
| iptables FORWARD policy |
DROP — managed by Docker |
| UFW |
Installed but inactive |
| fail2ban |
Not installed |
| Port |
Service |
Bound To |
Notes |
| 22 |
SSH |
0.0.0.0 |
Open to internet |
| 80 |
HTTP → docker-proxy → nginx |
0.0.0.0 |
Redirects to HTTPS |
| 443 |
HTTPS → docker-proxy → nginx |
0.0.0.0 |
TLS 1.2/1.3, HSTS |
| 5432 |
PostgreSQL |
0.0.0.0 |
Host process, restricted by pg_hba.conf to Docker subnet only |
| 5433 |
docker-proxy |
0.0.0.0 |
Unclear service |
| 2222 |
docker-proxy |
0.0.0.0 |
Unclear service |
| 3000 |
docker-proxy |
0.0.0.0 |
Some container |
| 6379 |
Redis |
127.0.0.1 |
Properly bound — good |
- 172.18.0.0/16 — Main service network (gp_booking_app, nginx, keycloak, wikijs, postgres)
- 172.20.0.0/16 — Secondary network (additional containers)
- 172.17.0.0/16 — Default docker bridge
- Docker iptables rules restrict FORWARD traffic; containers can only communicate via defined networks
| Mechanism |
Status |
| Subdomain routing (gp vs dental) |
✅ TenantMiddleware extracts subdomain, sets request.tenant |
| Separate Keycloak OIDC clients |
✅ Different clients per sector |
| Database routing by app_label |
✅ SectorDatabaseRouter separates GP/Dental DBs |
| Separate databases |
⚠️ GP and default currently share same physical DB (dsp_clinic) |
| Mechanism |
Status |
| StaffRoleRecord → Practice |
✅ User-to-practice mapping exists |
| Practice → Clinic FK |
✅ Practice.clinic links to slot_management.Clinic |
| Appointment → Clinic FK |
✅ But no direct FK to Practice |
# appointments/views.py:35-36
Appointment.objects.all() # No practice filtering
Receptionists and practice managers at any practice can see all appointments across all practices. The data model supports the chain (Appointment → Clinic ← Practice) but it is never enforced in view querysets.
| Issue |
Severity |
| DRF dental API — unfiltered querysets, any auth'd user can access all practices |
High |
| Celery workers bypass TenantMiddleware entirely (no thread-local tenant) |
Medium |
| DEBUG=True in production environment |
High |
| PostgreSQL listening on 0.0.0.0 (mitigated only by pg_hba.conf) |
Medium |
| No host-level firewall restricting inbound ports |
High |
| No fail2ban for SSH brute-force protection |
Medium |
| # |
Action |
Priority |
Reference |
| R1 |
Activate UFW on host — allow only ports 22, 80, 443, and Docker subnet for internal services |
High |
sops/ufw-rules.sh |
| R2 |
Add fail2ban for SSH protection |
High |
sops/install-fail2ban.sh |
| R3 |
Set DEBUG=False in production .env |
High |
dsp_clinic/settings.py |
| R4 |
Bind PostgreSQL to 127.0.0.1 instead of 0.0.0.0 (change listen_addresses in postgresql.conf) |
Medium |
postgres/postgresql.conf |
| R5 |
Practice-scope appointment querysets — filter by user's StaffRoleRecord.practice |
High |
appointments/views.py |
| # |
Action |
Priority |
Reference |
| H1 |
Add tenant-scoped filtering to all DRF API views (dental + integrations) |
High |
dental/api/views.py, integrations/ |
| H2 |
Add middleware or decorator to set tenant context in Celery tasks |
Medium |
tenancy/utils.py |
| H3 |
Implement practice-level queryset mixin for reuse across all views |
Medium |
core/mixins.py (new) |
| H4 |
Review and restrict Docker port publishing — remove unnecessary exposed ports (5433, 2222, 3000) |
Medium |
docker-compose.yml |
| H5 |
Implement audit logging of cross-boundary access attempts |
Medium |
auditing/ |
| # |
Action |
Priority |
| D1 |
Produce network architecture diagram showing VPS → Docker → Application boundaries |
Medium |
| D2 |
Document DSPT evidence response — IT supplier statement on firewall configuration |
Medium |
| D3 |
Create remediation tracking and re-assessment schedule |
Low |
Evidence statement for DSPT Standard 9:
The gp_booking_app platform is hosted on a Fasthosts NGCS VPS within the UK. All external traffic is terminated at an nginx reverse proxy enforcing TLS 1.2/1.3 with security headers (HSTS, CSP, X-Frame-Options). Application-layer boundaries are enforced via subdomain-based tenant identification, sector-level database routing via PostgreSQL, RBAC middleware, and Keycloak OIDC authentication.
[Note: Remediation items R1-R5 above must be completed before this statement can be finalised — specifically the host firewall activation, practice-level queryset scoping, and DEBUG=False.]
This assessment was generated from system inspection on 14 May 2026. Re-assessment recommended after remediation.
| ID |
Action |
Files Modified |
Status |
| R1 |
UFW host firewall — default deny inbound; allow only 22, 80, 443 + Docker subnets (172.16-20.x) |
Host OS (iptables/ufw) |
Done |
| R2 |
fail2ban — installed and configured with SSH jail |
Host OS (apt-get, systemd) |
Done |
| R3 |
DEBUG=False — env.production changed, container recreated |
config/secrets/env.production |
Done |
| R5 |
Practice-scoped appointment querysets — all 4 appointment views now filter by user's practice via StaffRoleRecord → Practice → Clinic chain |
appointments/views.py |
Done |
| H1 |
Tenant-scoped dental DRF API views — all 6 ViewSets (DentalPractice, DentalProvider, DentalAppointment, DentalClinicalNote, DentalTransaction, DentalRecallRule) now scope to user's dental practice via DentalProvider.user |
dental/api/views.py |
Done |
| H2 |
Celery tenant context helper — added with_tenant() context manager for non-HTTP code paths |
tenancy/utils.py |
Done |
| H3 |
Practice-level queryset mixin — PracticeScopedQuerySetMixin for reuse across views |
core/mixins.py |
Done |
| H4 |
Docker port publishing reviewed — mitigated by UFW (external ports from other containers now blocked) |
(review only) |
Done |
| H5 |
Cross-boundary audit logging — RBAC violations and practice boundary checks logged to AuditLog model with event types BOUNDARY_VIOLATION and PRACTICE_BOUNDARY |
core/middleware.py, appointments/views.py, core/mixins.py |
Done |
| ID |
Action |
Reason |
| R4 |
Bind PostgreSQL to 127.0.0.1 |
Deferred — UFW already blocks external access to port 5432; changing listen_addresses would risk breaking the Docker container-to-PostgreSQL connection |
19/19 tests passed inside the application container:
| Category |
Tests |
Result |
| Python syntax compilation |
5/5 |
All modified files compile cleanly |
| R3: DEBUG=False |
1/1 |
settings.DEBUG returns False |
| R5: Practice-scoped appointments |
3/3 |
Helper exists, superuser bypasses scoping, boundary audit logging works |
| H1: Dental API permissions |
3/3 |
Views import correctly, BelongsToDentalSector correctly allows dental roles and denies non-dental roles |
| H2: Celery tenant context |
2/2 |
with_tenant() works with both subdomain string and Tenant object, thread-local is properly cleared |
| H3: Practice-scoped mixin |
1/1 |
PracticeScopedQuerySetMixin imports and exposes expected methods |
| H5: Audit logging |
3/3 |
AuditLog model has required fields, RBAC middleware logs violations, boundary checks create AuditLog records |
Host-level verification:
| Component |
Check |
Result |
| UFW |
ufw status verbose |
Active — default deny inbound; ports 22, 80, 443 + Docker subnets allowed |
| fail2ban |
fail2ban-client status |
Active — SSH jail running |
| Application |
HTTPS health endpoint |
200 OK — nginx, Gunicorn, and Django all responding |
| Wiki |
HTTPS page access |
200 OK — this page renders correctly |