diff --git a/sdk/src/firetower_sdk/client.py b/sdk/src/firetower_sdk/client.py index c4f4f2ea..e976cd4e 100644 --- a/sdk/src/firetower_sdk/client.py +++ b/sdk/src/firetower_sdk/client.py @@ -94,6 +94,14 @@ def get_incident(self, incident_id: str) -> dict[str, Any]: """Get an incident by ID.""" return self._request("GET", f"/api/incidents/{incident_id}/") + def get_incident_status(self, incident_id: str) -> dict[str, Any]: + """Get the status of an incident by ID. + + Returns only the incident's id and status. Requires the + `incidents.view_all_incident_statuses` permission on the server. + """ + return self._request("GET", f"/api/incidents/{incident_id}/status/") + def list_incidents( self, statuses: list[IncidentStatus] | None = None, diff --git a/src/firetower/incidents/migrations/0017_alter_incident_options.py b/src/firetower/incidents/migrations/0017_alter_incident_options.py new file mode 100644 index 00000000..6e18939c --- /dev/null +++ b/src/firetower/incidents/migrations/0017_alter_incident_options.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.14 on 2026-05-15 19:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0016_schedule_demo"), + ] + + operations = [ + migrations.AlterModelOptions( + name="incident", + options={ + "ordering": ["-created_at"], + "permissions": [ + ( + "view_all_incident_statuses", + "Can view status of all incidents, including private", + ) + ], + }, + ), + ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 76b9e04e..ecb27b69 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -248,6 +248,12 @@ class Meta: models.Index(fields=["status", "-created_at"]), models.Index(fields=["severity", "-created_at"]), ] + permissions = [ + ( + "view_all_incident_statuses", + "Can view status of all incidents, including private", + ), + ] @property def incident_number(self) -> str: diff --git a/src/firetower/incidents/permissions.py b/src/firetower/incidents/permissions.py index b42cb169..a6fd85ff 100644 --- a/src/firetower/incidents/permissions.py +++ b/src/firetower/incidents/permissions.py @@ -22,6 +22,20 @@ def has_permission(self, request: Request, view: "APIView") -> bool: return request.user and request.user.is_authenticated +class IncidentStatusPermission(permissions.BasePermission): + """ + Permission for the incident status endpoint. + + Requires the django-admin `incidents.view_all_incident_statuses` permission, + which grants access to the status of any incident (including private ones). + """ + + def has_permission(self, request: Request, view: "APIView") -> bool: + if not (request.user and request.user.is_authenticated): + return False + return request.user.has_perm("incidents.view_all_incident_statuses") + + class IncidentPermission(permissions.BasePermission): """ Permission class for incident CRUD operations. diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 7a5c6cb3..d2622ddb 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -220,6 +220,17 @@ def get_participants(self, obj: Incident) -> list[ParticipantData]: return participants_list +class IncidentStatusSerializer(serializers.ModelSerializer): + """Serializer that exposes only the incident's status.""" + + id = serializers.CharField(source="incident_number", read_only=True) + + class Meta: + model = Incident + fields = ["id", "status"] + read_only_fields = ["id", "status"] + + class IncidentReadSerializer(serializers.ModelSerializer): """ Serializer for reading incidents via the service API. diff --git a/src/firetower/incidents/tests/test_views.py b/src/firetower/incidents/tests/test_views.py index e103e574..3a9e02ed 100644 --- a/src/firetower/incidents/tests/test_views.py +++ b/src/firetower/incidents/tests/test_views.py @@ -4,7 +4,7 @@ import pytest from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User from rest_framework.test import APIClient from firetower.incidents.models import ( @@ -1481,3 +1481,154 @@ def test_list_tags_sorted_by_usage(self): assert response.status_code == 200 assert response.data == ["UsedTwice", "UsedOnce", "Unused"] + + +@pytest.mark.django_db +class TestIncidentStatusRetrieveAPIView: + """Tests for GET /api/incidents/{id}/status/.""" + + def setup_method(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="test@example.com", + email="test@example.com", + password="testpass123", + ) + self.other_user = User.objects.create_user( + username="other@example.com", + email="other@example.com", + password="testpass123", + ) + + def _grant_view_all_statuses(self, user: User) -> None: + perm = Permission.objects.get(codename="view_all_incident_statuses") + user.user_permissions.add(perm) + + def test_returns_only_id_and_status(self): + """Response exposes only id and status — no title or other fields.""" + incident = Incident.objects.create( + title="Secret Title", + description="Sensitive description", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 200 + assert response.data == { + "id": incident.incident_number, + "status": IncidentStatus.ACTIVE, + } + + def test_visible_user_can_read_public_incident_status(self): + """User with normal read visibility (public incident) gets status.""" + incident = Incident.objects.create( + title="Public", + status=IncidentStatus.MITIGATED, + severity=IncidentSeverity.P2, + is_private=False, + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 200 + assert response.data["status"] == IncidentStatus.MITIGATED + + def test_captain_can_read_private_incident_status_via_visibility(self): + """Captain of a private incident has visibility, so no special perm needed.""" + incident = Incident.objects.create( + title="Private", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + captain=self.user, + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 200 + assert response.data["status"] == IncidentStatus.ACTIVE + + def test_user_without_visibility_or_perm_is_denied(self): + """User who can't see a private incident and lacks the perm is denied.""" + incident = Incident.objects.create( + title="Other's Private", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + captain=self.other_user, + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 403 + + def test_user_with_view_all_perm_can_read_private_status(self): + """User without visibility but holding view_all_incident_statuses gets through.""" + incident = Incident.objects.create( + title="Other's Private", + status=IncidentStatus.DONE, + severity=IncidentSeverity.P1, + is_private=True, + captain=self.other_user, + ) + self._grant_view_all_statuses(self.user) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 200 + assert response.data == { + "id": incident.incident_number, + "status": IncidentStatus.DONE, + } + + def test_unauthenticated_user_denied(self): + incident = Incident.objects.create( + title="Public", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code in (401, 403) + + def test_superuser_can_read_private_incident_status(self): + superuser = User.objects.create_superuser( + username="admin@example.com", + email="admin@example.com", + password="testpass123", + ) + incident = Incident.objects.create( + title="Other's Private", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + captain=self.other_user, + ) + + self.client.force_authenticate(user=superuser) + response = self.client.get(f"/api/incidents/{incident.incident_number}/status/") + + assert response.status_code == 200 + assert response.data["status"] == IncidentStatus.ACTIVE + + def test_invalid_format_returns_400(self): + self.client.force_authenticate(user=self.user) + response = self.client.get("/api/incidents/INVALID-123/status/") + + assert response.status_code == 400 + + def test_not_found_returns_404(self): + self.client.force_authenticate(user=self.user) + response = self.client.get( + f"/api/incidents/{settings.PROJECT_KEY}-99999/status/" + ) + + assert response.status_code == 404 diff --git a/src/firetower/incidents/urls.py b/src/firetower/incidents/urls.py index 42d5406e..f54e3bd1 100644 --- a/src/firetower/incidents/urls.py +++ b/src/firetower/incidents/urls.py @@ -4,6 +4,7 @@ AvailabilityView, IncidentListCreateAPIView, IncidentRetrieveUpdateAPIView, + IncidentStatusRetrieveAPIView, TagListCreateAPIView, incident_detail_ui, incident_list_ui, @@ -30,6 +31,11 @@ IncidentRetrieveUpdateAPIView.as_view(), name="incident-retrieve-update", ), + path( + "incidents//status/", + IncidentStatusRetrieveAPIView.as_view(), + name="incident-status-retrieve", + ), path( "incidents//sync-participants/", sync_incident_participants, diff --git a/src/firetower/incidents/views.py b/src/firetower/incidents/views.py index 91556adb..093e5122 100644 --- a/src/firetower/incidents/views.py +++ b/src/firetower/incidents/views.py @@ -30,7 +30,7 @@ TagType, filter_visible_to_user, ) -from .permissions import IncidentPermission +from .permissions import IncidentPermission, IncidentStatusPermission from .reporting_utils import ( build_incidents_by_tag, compute_regions, @@ -42,6 +42,7 @@ IncidentListUISerializer, IncidentOrRedirectReadSerializer, IncidentReadSerializer, + IncidentStatusSerializer, IncidentWriteSerializer, TagCreateSerializer, TagSerializer, @@ -265,6 +266,46 @@ def get_object(self) -> Incident: return obj +class IncidentStatusRetrieveAPIView(generics.RetrieveAPIView): + """ + Service API for retrieving an incident's status only. + + GET: Get incident status + + Accepts incident_id in format: INC-2000 + + Access is granted if either: + - The user has normal read visibility to the incident (IncidentPermission), or + - The user has the `incidents.view_all_incident_statuses` permission + (IncidentStatusPermission), which grants status access to any incident + including private ones. + """ + + permission_classes = [IncidentPermission | IncidentStatusPermission] + serializer_class = IncidentStatusSerializer + lookup_field = "id" + + def get_queryset(self) -> QuerySet[Incident]: + return Incident.objects.all() + + def get_object(self) -> Incident: + incident_id = self.kwargs["incident_id"] + project_key = settings.PROJECT_KEY + + incident_pattern = rf"^{re.escape(project_key)}-(\d+)$" + match = re.match(incident_pattern, incident_id, re.IGNORECASE) + + if not match: + raise ValidationError( + f"Invalid incident ID format. Expected format: {project_key}- (e.g., {project_key}-123)" + ) + + numeric_id = int(match.group(1)) + obj = get_object_or_404(self.get_queryset(), id=numeric_id) + self.check_object_permissions(self.request, obj) + return obj + + class SyncIncidentParticipantsView(generics.GenericAPIView): """ Force sync incident participants from Slack channel.