From 929dc89664fedf44b3d51d60f6f7e56c8fc50319 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Tue, 7 Jul 2026 14:08:02 +0200 Subject: [PATCH 1/4] Track document downloads - v1 --- analytics/__init__.py | 0 analytics/admin.py | 27 +++++ analytics/apps.py | 7 ++ analytics/management/__init__.py | 0 analytics/management/commands/__init__.py | 0 .../commands/prune_download_logs.py | 15 +++ analytics/migrations/0001_initial.py | 84 +++++++++++++ analytics/migrations/__init__.py | 0 analytics/models.py | 112 ++++++++++++++++++ analytics/serializers.py | 24 ++++ analytics/views.py | 45 +++++++ main/settings.py | 1 + main/urls.py | 4 + 13 files changed, 319 insertions(+) create mode 100644 analytics/__init__.py create mode 100644 analytics/admin.py create mode 100644 analytics/apps.py create mode 100644 analytics/management/__init__.py create mode 100644 analytics/management/commands/__init__.py create mode 100644 analytics/management/commands/prune_download_logs.py create mode 100644 analytics/migrations/0001_initial.py create mode 100644 analytics/migrations/__init__.py create mode 100644 analytics/models.py create mode 100644 analytics/serializers.py create mode 100644 analytics/views.py diff --git a/analytics/__init__.py b/analytics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/admin.py b/analytics/admin.py new file mode 100644 index 000000000..5231b3f06 --- /dev/null +++ b/analytics/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from .models import DocumentDownloadLog + + +@admin.register(DocumentDownloadLog) +class DocumentDownloadLogAdmin(admin.ModelAdmin): + list_display = ("downloaded_at", "document_type", "source", "object_id", "user", "ip_address") + list_filter = ("document_type", "source") + date_hierarchy = "downloaded_at" + search_fields = ("url", "user__username", "user__email") + readonly_fields = ( + "document_type", + "object_id", + "url", + "source", + "user", + "downloaded_at", + "ip_address", + ) + ordering = ("-downloaded_at",) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/analytics/apps.py b/analytics/apps.py new file mode 100644 index 000000000..037da2200 --- /dev/null +++ b/analytics/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AnalyticsConfig(AppConfig): + name = "analytics" + verbose_name = _("analytics") diff --git a/analytics/management/__init__.py b/analytics/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/management/commands/__init__.py b/analytics/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/management/commands/prune_download_logs.py b/analytics/management/commands/prune_download_logs.py new file mode 100644 index 000000000..45948772f --- /dev/null +++ b/analytics/management/commands/prune_download_logs.py @@ -0,0 +1,15 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from analytics.models import DocumentDownloadLog + + +class Command(BaseCommand): + help = "Delete DocumentDownloadLog entries older than 6 months." + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=183) + deleted, _ = DocumentDownloadLog.objects.filter(downloaded_at__lt=cutoff).delete() + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} document download log entries older than 6 months.")) diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py new file mode 100644 index 000000000..e9e915ab2 --- /dev/null +++ b/analytics/migrations/0001_initial.py @@ -0,0 +1,84 @@ +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DocumentDownloadLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "document_type", + models.CharField( + choices=[ + ("situation_report", "Situation Report"), + ("appeal_document", "Appeal Document"), + ("general_document", "General Document"), + ("country_document", "Country Key Document"), + ("featured_document", "Event Featured Document"), + ("dref_file", "DREF File"), + ("per_document", "PER Document"), + ("other", "Other"), + ], + max_length=50, + verbose_name="document type", + ), + ), + ( + "object_id", + models.PositiveIntegerField(blank=True, db_index=True, null=True, verbose_name="object id"), + ), + ("url", models.URLField(max_length=2000, verbose_name="url")), + ( + "source", + models.CharField( + choices=[ + ("azure_blob", "Azure Blob"), + ("adore", "Adore"), + ("sharepoint", "SharePoint"), + ("other", "Other"), + ], + default="other", + max_length=20, + verbose_name="source", + ), + ), + ( + "downloaded_at", + models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="downloaded at"), + ), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, null=True, unpack_ipv4=True, verbose_name="ip address" + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="document_download_logs", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "document download log", + "verbose_name_plural": "document download logs", + "ordering": ("-downloaded_at",), + }, + ), + ] diff --git a/analytics/migrations/__init__.py b/analytics/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/models.py b/analytics/models.py new file mode 100644 index 000000000..5d555125f --- /dev/null +++ b/analytics/models.py @@ -0,0 +1,112 @@ +import ipaddress + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +def mask_ip_address(ip: str) -> str | None: + """ + Return the IP address with its first segment zeroed out for privacy. + IPv4: 192.168.1.5 -> 0.168.1.5 + IPv6: 2001:db8::1 -> 0:db8::1 + """ + if not ip: + return None + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return None + if addr.version == 4: + parts = ip.split(".") + parts[0] = "0" + return ".".join(parts) + # IPv6 – zero the first group + # Expand so we always have 8 groups before masking + expanded = ipaddress.ip_address(ip).exploded + parts = expanded.split(":") + parts[0] = "0000" + return ":".join(parts) + + +class DocumentDownloadLog(models.Model): + class DocumentSource(models.TextChoices): + AZURE_BLOB = "azure_blob", _("Azure Blob") + ADORE = "adore", _("Adore") + SHAREPOINT = "sharepoint", _("SharePoint") + OTHER = "other", _("Other") + + class DocumentType(models.TextChoices): + SITUATION_REPORT = "situation_report", _("Situation Report") + APPEAL_DOCUMENT = "appeal_document", _("Appeal Document") + GENERAL_DOCUMENT = "general_document", _("General Document") + COUNTRY_DOCUMENT = "country_document", _("Country Key Document") + FEATURED_DOCUMENT = "featured_document", _("Event Featured Document") + DREF_FILE = "dref_file", _("DREF File") + PER_DOCUMENT = "per_document", _("PER Document") + OTHER = "other", _("Other") + + document_type = models.CharField( + verbose_name=_("document type"), + max_length=50, + choices=DocumentType.choices, + ) + # PK of the source record – nullable for purely external documents + object_id = models.PositiveIntegerField( + verbose_name=_("object id"), + null=True, + blank=True, + db_index=True, + ) + url = models.URLField( + verbose_name=_("url"), + max_length=2000, + ) + source = models.CharField( + verbose_name=_("source"), + max_length=20, + choices=DocumentSource.choices, + default=DocumentSource.OTHER, + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("user"), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="document_download_logs", + ) + downloaded_at = models.DateTimeField( + verbose_name=_("downloaded at"), + auto_now_add=True, + db_index=True, + ) + # First segment zeroed; e.g. 0.168.1.5 for IPv4 + ip_address = models.GenericIPAddressField( + verbose_name=_("ip address"), + null=True, + blank=True, + unpack_ipv4=True, + ) + + class Meta: + verbose_name = _("document download log") + verbose_name_plural = _("document download logs") + ordering = ("-downloaded_at",) + + def __str__(self): + return f"{self.document_type} / {self.object_id} @ {self.downloaded_at}" + + +def detect_source(url: str) -> str: + """Classify a download URL into a DocumentSource choice.""" + azure_account = getattr(settings, "AZURE_STORAGE_ACCOUNT", None) + if azure_account and azure_account in url: + return DocumentDownloadLog.DocumentSource.AZURE_BLOB + if "blob.core.windows.net" in url: + return DocumentDownloadLog.DocumentSource.AZURE_BLOB + if "adore.ifrc.org" in url: + return DocumentDownloadLog.DocumentSource.ADORE + if "sharepoint.com" in url: + return DocumentDownloadLog.DocumentSource.SHAREPOINT + return DocumentDownloadLog.DocumentSource.OTHER diff --git a/analytics/serializers.py b/analytics/serializers.py new file mode 100644 index 000000000..2d1d7727f --- /dev/null +++ b/analytics/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + +from .models import DocumentDownloadLog + + +class DocumentDownloadLogSerializer(serializers.ModelSerializer): + class Meta: + model = DocumentDownloadLog + fields = ("id", "document_type", "object_id", "url", "source") + read_only_fields = ("id",) + + def validate_document_type(self, value): + if value not in DocumentDownloadLog.DocumentType.values: + raise serializers.ValidationError( + f"Invalid document_type. Choose from: {', '.join(DocumentDownloadLog.DocumentType.values)}" + ) + return value + + def validate_source(self, value): + if value not in DocumentDownloadLog.DocumentSource.values: + raise serializers.ValidationError( + f"Invalid source. Choose from: {', '.join(DocumentDownloadLog.DocumentSource.values)}" + ) + return value diff --git a/analytics/views.py b/analytics/views.py new file mode 100644 index 000000000..01b6b9c8e --- /dev/null +++ b/analytics/views.py @@ -0,0 +1,45 @@ +from rest_framework import mixins, viewsets +from rest_framework.authentication import TokenAuthentication + +from .models import DocumentDownloadLog, detect_source, mask_ip_address +from .serializers import DocumentDownloadLogSerializer + + +def _get_client_ip(request) -> str | None: + forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + +class DocumentDownloadLogViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + """ + Log a document download event. + + The frontend calls this endpoint (fire-and-forget) each time a user + initiates a download. Authentication is optional: anonymous downloads + are recorded with a null user. + """ + + authentication_classes = (TokenAuthentication,) + # No permission_classes – allow unauthenticated requests + permission_classes = [] + serializer_class = DocumentDownloadLogSerializer + + def perform_create(self, serializer): + url = serializer.validated_data.get("url", "") + # Auto-detect source from URL when not explicitly provided or defaulted + source = serializer.validated_data.get("source") or detect_source(url) + if source == DocumentDownloadLog.DocumentSource.OTHER: + source = detect_source(url) + + raw_ip = _get_client_ip(self.request) + masked_ip = mask_ip_address(raw_ip) if raw_ip else None + + user = self.request.user if self.request.user.is_authenticated else None + + serializer.save( + user=user, + ip_address=masked_ip, + source=source, + ) diff --git a/main/settings.py b/main/settings.py index a500bef8c..c12cbc4ac 100644 --- a/main/settings.py +++ b/main/settings.py @@ -243,6 +243,7 @@ def parse_domain(*env_keys: str) -> str: "country_plan", "local_units", "alert_system", + "analytics", ] INSTALLED_APPS = [ diff --git a/main/urls.py b/main/urls.py index ddf055962..e0c6a0484 100644 --- a/main/urls.py +++ b/main/urls.py @@ -22,6 +22,7 @@ from rest_framework import routers from alert_system.dev_views import AlertEmailPreview +from analytics import views as analytics_views from api import drf_views as api_views from api.admin_reports import UsersPerPermissionViewSet from api.views import ( @@ -214,6 +215,9 @@ router.register(r"eap/global-files", eap_views.EAPGlobalFilesViewSet, basename="eap_global_files") router.register(r"eap-share-users", eap_views.EAPShareUserViewSet, basename="eap_share_users") +# Analytics +router.register(r"document-download-log", analytics_views.DocumentDownloadLogViewSet, basename="document_download_log") + admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" From 14389fa3ee84edb42def77b92a4b37df3e293ea5 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Wed, 8 Jul 2026 07:03:03 +0200 Subject: [PATCH 2/4] Update artifacts --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index 3d53802f7..54adb8aff 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 3d53802f775bbe733741843d95a6fbcbf27f671e +Subproject commit 54adb8aff2ca0fd75983f5244b1601736c34d330 From f295f6978f1cf2a69ba4b7e7058131e70e489832 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Thu, 9 Jul 2026 11:24:12 +0200 Subject: [PATCH 3/4] Add AZURE_BLOB as an External Source --- analytics/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/analytics/models.py b/analytics/models.py index 5d555125f..b4da44e1d 100644 --- a/analytics/models.py +++ b/analytics/models.py @@ -31,8 +31,11 @@ def mask_ip_address(ip: str) -> str | None: class DocumentDownloadLog(models.Model): class DocumentSource(models.TextChoices): + # External Source AZURE_BLOB = "azure_blob", _("Azure Blob") + # Internal Source ADORE = "adore", _("Adore") + GOAPI = "goapi", _("GOAPI") SHAREPOINT = "sharepoint", _("SharePoint") OTHER = "other", _("Other") From c9da6b0216686feab1e71f6ae35db868c0e00879 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Sat, 11 Jul 2026 13:20:24 +0200 Subject: [PATCH 4/4] Simplify download log validation and reuse readonly admin mixin --- alert_system/etl/usgs_earthquake/transform.py | 2 +- analytics/admin.py | 10 ++--- .../0002_alter_documentdownloadlog_source.py | 18 ++++++++ analytics/models.py | 2 + analytics/serializers.py | 14 ------ api/management/commands/ingest_appeals.py | 2 +- api/management/commands/sync_molnix.py | 22 +++++----- assets | 2 +- .../templates/config/configmap.yaml | 3 ++ deploy/helm/ifrcgo-helm/values.yaml | 3 ++ docker-compose.yml | 2 + main/settings.py | 43 ++++++++++++++++--- 12 files changed, 81 insertions(+), 42 deletions(-) create mode 100644 analytics/migrations/0002_alter_documentdownloadlog_source.py diff --git a/alert_system/etl/usgs_earthquake/transform.py b/alert_system/etl/usgs_earthquake/transform.py index b7f2cd2dc..520a0de4c 100644 --- a/alert_system/etl/usgs_earthquake/transform.py +++ b/alert_system/etl/usgs_earthquake/transform.py @@ -82,7 +82,7 @@ def process_event(self, event_item) -> BaseTransformerClass.EventType: "title": properties.get("title", ""), "description": properties.get("description", ""), "country": properties.get("monty:country_codes", ""), - "start_datetime": properties.get("start_datetime"), + "start_datetime": properties.get("datetime"), "end_datetime": properties.get("end_datetime"), "episode_number": properties.get("monty:episode_number"), "event_url": next(item["href"] for item in urls if item["rel"] == "self"), diff --git a/analytics/admin.py b/analytics/admin.py index 5231b3f06..ff70aadb9 100644 --- a/analytics/admin.py +++ b/analytics/admin.py @@ -1,10 +1,12 @@ from django.contrib import admin +from dref.admin import ReadOnlyMixin + from .models import DocumentDownloadLog @admin.register(DocumentDownloadLog) -class DocumentDownloadLogAdmin(admin.ModelAdmin): +class DocumentDownloadLogAdmin(ReadOnlyMixin, admin.ModelAdmin): list_display = ("downloaded_at", "document_type", "source", "object_id", "user", "ip_address") list_filter = ("document_type", "source") date_hierarchy = "downloaded_at" @@ -19,9 +21,3 @@ class DocumentDownloadLogAdmin(admin.ModelAdmin): "ip_address", ) ordering = ("-downloaded_at",) - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False diff --git a/analytics/migrations/0002_alter_documentdownloadlog_source.py b/analytics/migrations/0002_alter_documentdownloadlog_source.py new file mode 100644 index 000000000..3e03d6e81 --- /dev/null +++ b/analytics/migrations/0002_alter_documentdownloadlog_source.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-07-11 11:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='documentdownloadlog', + name='source', + field=models.CharField(choices=[('azure_blob', 'Azure Blob'), ('adore', 'Adore'), ('goapi', 'GOAPI'), ('sharepoint', 'SharePoint'), ('other', 'Other')], default='other', max_length=20, verbose_name='source'), + ), + ] diff --git a/analytics/models.py b/analytics/models.py index b4da44e1d..bc43c6f1f 100644 --- a/analytics/models.py +++ b/analytics/models.py @@ -108,6 +108,8 @@ def detect_source(url: str) -> str: return DocumentDownloadLog.DocumentSource.AZURE_BLOB if "blob.core.windows.net" in url: return DocumentDownloadLog.DocumentSource.AZURE_BLOB + if "go-api.ifrc.org" in url: + return DocumentDownloadLog.DocumentSource.GOAPI if "adore.ifrc.org" in url: return DocumentDownloadLog.DocumentSource.ADORE if "sharepoint.com" in url: diff --git a/analytics/serializers.py b/analytics/serializers.py index 2d1d7727f..868d1b665 100644 --- a/analytics/serializers.py +++ b/analytics/serializers.py @@ -8,17 +8,3 @@ class Meta: model = DocumentDownloadLog fields = ("id", "document_type", "object_id", "url", "source") read_only_fields = ("id",) - - def validate_document_type(self, value): - if value not in DocumentDownloadLog.DocumentType.values: - raise serializers.ValidationError( - f"Invalid document_type. Choose from: {', '.join(DocumentDownloadLog.DocumentType.values)}" - ) - return value - - def validate_source(self, value): - if value not in DocumentDownloadLog.DocumentSource.values: - raise serializers.ValidationError( - f"Invalid source. Choose from: {', '.join(DocumentDownloadLog.DocumentSource.values)}" - ) - return value diff --git a/api/management/commands/ingest_appeals.py b/api/management/commands/ingest_appeals.py index f0b3a8988..c199ec263 100644 --- a/api/management/commands/ingest_appeals.py +++ b/api/management/commands/ingest_appeals.py @@ -131,7 +131,7 @@ def get_new_or_modified_appeals(self): # get latest APPEALS logger.info("Querying appeals API for new appeals data") - url = "https://go-api.ifrc.org/api/appeals" # DEBUG: can append filter &app_code=MDRDJ003 + url = "https://go-api.ifrc.org/api/appealsD365" # DEBUG: can append filter &app_code=MDRDJ003 params = {"App_startDate": "2023-07-01|2100-01-01"} # try 3 times to reach the API try: diff --git a/api/management/commands/sync_molnix.py b/api/management/commands/sync_molnix.py index c9ae86916..cc27fc7d8 100644 --- a/api/management/commands/sync_molnix.py +++ b/api/management/commands/sync_molnix.py @@ -293,9 +293,15 @@ def sync_deployments(molnix_deployments, molnix_api, countries): # Create Personnel objects for md in molnix_deployments: # XXX: LOOP 3 - if "position_id" not in md: # changed structure § + person = md.get("person") or {} + + # The deployment list payload usually has person.fullname but not person.sex. + # Fetch full deployment detail when person.sex is missing. + if "position_id" not in md or "sex" not in person: # changed structure § md2 = molnix_api.get_deployment(md["id"]) - md |= md2["deployment"] # XXX: Potential issue due to mutation? + md.update(md2.get("deployment") or {}) + + person = md.get("person") or {} if skip_this(md["tags"]): warning = "Deployment id %d skipped due to No-GO" % md["id"] logger.warning(warning) @@ -343,15 +349,7 @@ def sync_deployments(molnix_deployments, molnix_api, countries): appraisal_received = "appraisals" in md and bool(len(md["appraisals"])) - gender = None - try: - if md["person"] and "sex" in md["person"]: - gender = md["person"]["sex"] - except Exception: - warning = "Did not find gender info in %d" % md["id"] - logger.warning(warning) - warnings.append(warning) - continue + gender = person.get("sex") or None location = None try: @@ -380,7 +378,7 @@ def sync_deployments(molnix_deployments, molnix_api, countries): personnel.type = Personnel.TypeChoices.RR personnel.start_date = get_datetime(md["start"]) personnel.end_date = get_datetime(md["end"]) - personnel.name = md["person"]["fullname"] + personnel.name = person.get("fullname") personnel.role = md["title"] country_to = get_go_country(countries, md["country_id"]) if not country_to: diff --git a/assets b/assets index 54adb8aff..3fef0a515 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 54adb8aff2ca0fd75983f5244b1601736c34d330 +Subproject commit 3fef0a515af4f3f1f46093574330ce69b742803b diff --git a/deploy/helm/ifrcgo-helm/templates/config/configmap.yaml b/deploy/helm/ifrcgo-helm/templates/config/configmap.yaml index d8818b779..cde1f93d1 100644 --- a/deploy/helm/ifrcgo-helm/templates/config/configmap.yaml +++ b/deploy/helm/ifrcgo-helm/templates/config/configmap.yaml @@ -34,6 +34,9 @@ data: ELASTIC_SEARCH_INDEX: {{ .Values.env.ELASTIC_SEARCH_INDEX | quote }} DOCKER_HOST_IP: {{ .Values.env.DOCKER_HOST_IP | quote }} DJANGO_ADDITIONAL_ALLOWED_HOSTS: {{ .Values.env.DJANGO_ADDITIONAL_ALLOWED_HOSTS | quote }} + ADDITIONAL_TRUSTED_ORIGINS: {{ .Values.env.ADDITIONAL_TRUSTED_ORIGINS | quote }} + SESSION_COOKIE_DOMAIN: {{ .Values.env.SESSION_COOKIE_DOMAIN | quote }} + CSRF_COOKIE_DOMAIN: {{ .Values.env.CSRF_COOKIE_DOMAIN | quote }} GO_ENVIRONMENT: {{ .Values.env.GO_ENVIRONMENT | quote }} API_FQDN: {{ .Values.env.API_FQDN | quote }} FRONTEND_URL: {{ .Values.env.FRONTEND_URL | quote }} diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 98659c2be..f4f3f072f 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -39,6 +39,9 @@ env: DJANGO_DEBUG: '' DOCKER_HOST_IP: '' DJANGO_ADDITIONAL_ALLOWED_HOSTS: '' + ADDITIONAL_TRUSTED_ORIGINS: '' + SESSION_COOKIE_DOMAIN: '' + CSRF_COOKIE_DOMAIN: '' GO_ENVIRONMENT: '' API_FQDN: '' FRONTEND_URL: '' diff --git a/docker-compose.yml b/docker-compose.yml index 6ff7c3f57..2aa673ec4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ x-server: &base_server_setup PLAYWRIGHT_SERVER_URL: ${PLAYWRIGHT_SERVER_URL:-ws://playwright:3000/} GO_WEB_INTERNAL_URL: ${GO_WEB_INTERNAL_URL:-http://host.docker.internal:3000} DJANGO_ADDITIONAL_ALLOWED_HOSTS: ${DJANGO_ADDITIONAL_ALLOWED_HOSTS:-host.docker.internal,127.0.0.1,localhost} + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-localhost} + CSRF_COOKIE_DOMAIN: ${CSRF_COOKIE_DOMAIN:-localhost} DEBUG_EMAIL: ${DEBUG_EMAIL:-true} MOLNIX_API_BASE: ${MOLNIX_API_BASE:-https://api.ifrc-staging.rpm.molnix.com/api/} ERP_API_ENDPOINT: ${ERP_API_ENDPOINT:-https://ifrctintapim001.azure-api.net/GoAPI/ExtractGoEmergency} diff --git a/main/settings.py b/main/settings.py index c12cbc4ac..3d5afc918 100644 --- a/main/settings.py +++ b/main/settings.py @@ -29,7 +29,9 @@ DJANGO_STATIC_URL=(str, "/static/"), DJANGO_ADDITIONAL_ALLOWED_HOSTS=(list, []), # Eg: api.go.ifrc.org, goadmin.ifrc.org, dsgocdnapi.azureedge.net GO_ENVIRONMENT=(str, "development"), # staging, production - # + SESSION_COOKIE_DOMAIN=str, + CSRF_COOKIE_DOMAIN=str, + ADDITIONAL_TRUSTED_ORIGINS=(list, []), API_FQDN=str, # https://goadmin.ifrc.org FRONTEND_URL=str, # https://go.ifrc.org GO_WEB_INTERNAL_URL=(str, None), # http://host.docker.internal @@ -179,8 +181,8 @@ def parse_domain(*env_keys: str) -> str: # NOTE: Used in local development to point to the frontend service from within go-api container # Default to GO_WEB_URL if GO_WEB_INTERNAL_URL is not provided GO_WEB_INTERNAL_URL = parse_domain("GO_WEB_INTERNAL_URL", "FRONTEND_URL") -FRONTEND_URL = urlparse(GO_WEB_URL).hostname # FIXME: Deprecated. Slowly remove this from codebase +FRONTEND_URL = urlparse(GO_WEB_URL).hostname # FIXME: Deprecated. Slowly remove this from codebase PLAYWRIGHT_SERVER_URL = env("PLAYWRIGHT_SERVER_URL") INTERNAL_IPS = ["127.0.0.1"] @@ -472,10 +474,39 @@ def parse_domain(*env_keys: str) -> str: IFRC_TRANSLATION_DOMAIN = env("IFRC_TRANSLATION_DOMAIN") IFRC_TRANSLATION_HEADER_API_KEY = env("IFRC_TRANSLATION_HEADER_API_KEY") -# Needed to generate correct https links when running behind a reverse proxy -# when SSL is terminated at the proxy -USE_X_FORWARDED_HOST = True -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_SCHEME", "https") + +# -- Security Headers -- +GO_TRUSTED_ORIGINS = [ + GO_WEB_URL, + GO_API_URL, + *env("ADDITIONAL_TRUSTED_ORIGINS"), +] + +SESSION_COOKIE_NAME = f"GO-{GO_ENVIRONMENT}-SESSIONID" +CSRF_COOKIE_NAME = f"GO-{GO_ENVIRONMENT}-CSRFTOKEN" +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" +CSP_DEFAULT_SRC = ["'self'"] +SECURE_REFERRER_POLICY = "same-origin" + +if urlparse(GO_API_URL).scheme == "https": + SESSION_COOKIE_NAME = f"__Secure-{SESSION_COOKIE_NAME}" + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + CSRF_COOKIE_SECURE = True + SECURE_HSTS_SECONDS = 30 # TODO: Increase this slowly + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +CSRF_TRUSTED_ORIGINS = GO_TRUSTED_ORIGINS + +# https://docs.djangoproject.com/en/4.2/ref/settings/#std:setting-SESSION_COOKIE_DOMAIN +SESSION_COOKIE_DOMAIN = env("SESSION_COOKIE_DOMAIN") +# https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-cookie-domain +CSRF_COOKIE_DOMAIN = env("CSRF_COOKIE_DOMAIN") + # Storage MEDIA_URL = env("DJANGO_MEDIA_URL")