Skip to content
Closed
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"integrations.launch_darkly",
"integrations.github",
"integrations.gitlab",
"integrations.azure_devops",
"integrations.grafana",
"integrations.vcs",
# Rate limiting admin endpoints
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions api/integrations/azure_devops/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class AzureDevOpsIntegrationConfig(AppConfig):
name = "integrations.azure_devops"
111 changes: 111 additions & 0 deletions api/integrations/azure_devops/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Generated by Django 5.2.14 on 2026-05-28 11:57

import django.db.models.deletion
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("projects", "0029_bump_default_project_limits"),
]

operations = [
migrations.CreateModel(
name="AzureDevOpsConfiguration",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"deleted_at",
models.DateTimeField(
blank=True,
db_index=True,
default=None,
editable=False,
null=True,
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("organisation_url", models.URLField(max_length=300)),
("personal_access_token", models.CharField(max_length=300)),
("labeling_enabled", models.BooleanField(default=False)),
("tagging_enabled", models.BooleanField(default=False)),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="azure_devops_config",
to="projects.project",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AzureDevOpsServiceHook",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"deleted_at",
models.DateTimeField(
blank=True,
db_index=True,
default=None,
editable=False,
null=True,
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("ado_project_id", models.UUIDField()),
("ado_project_name", models.CharField(max_length=200)),
("event_type", models.CharField(max_length=64)),
("subscription_id", models.UUIDField()),
("secret", models.CharField(max_length=128)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"configuration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_hooks",
to="azure_devops.azuredevopsconfiguration",
),
),
],
options={
"constraints": [
models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("configuration", "ado_project_id", "event_type"),
name="unique_azure_devops_service_hook_per_event",
)
],
},
),
]
Empty file.
38 changes: 38 additions & 0 deletions api/integrations/azure_devops/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django.db import models

from core.models import SoftDeleteExportableModel


class AzureDevOpsConfiguration(SoftDeleteExportableModel):
project = models.OneToOneField(
"projects.Project",
on_delete=models.CASCADE,
related_name="azure_devops_config",
)
organisation_url = models.URLField(max_length=300)
personal_access_token = models.CharField(max_length=300)
labeling_enabled = models.BooleanField(default=False)
tagging_enabled = models.BooleanField(default=False)


class AzureDevOpsServiceHook(SoftDeleteExportableModel):
configuration = models.ForeignKey(
AzureDevOpsConfiguration,
on_delete=models.CASCADE,
related_name="service_hooks",
)
ado_project_id = models.UUIDField()
ado_project_name = models.CharField(max_length=200)
event_type = models.CharField(max_length=64)
subscription_id = models.UUIDField()
secret = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["configuration", "ado_project_id", "event_type"],
name="unique_azure_devops_service_hook_per_event",
condition=models.Q(deleted_at__isnull=True),
),
]
23 changes: 23 additions & 0 deletions api/integrations/azure_devops/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any

from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.common.serializers import BaseProjectIntegrationModelSerializer

WRITE_ONLY_PLACEHOLDER = "write-only"


class AzureDevOpsConfigurationSerializer(BaseProjectIntegrationModelSerializer):
class Meta:
model = AzureDevOpsConfiguration
fields = (
"id",
"organisation_url",
"personal_access_token",
"labeling_enabled",
"tagging_enabled",
)

def to_representation(self, instance: AzureDevOpsConfiguration) -> dict[str, Any]:
data = super().to_representation(instance)
data["personal_access_token"] = WRITE_ONLY_PLACEHOLDER
return data
Comment on lines +1 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Security & Correctness Issue: Token Overwrite Bug

When a client retrieves the configuration, the personal_access_token is masked with the placeholder value "write-only" in to_representation.

However, if the client subsequently performs an update (e.g., a PUT request to toggle labeling_enabled or tagging_enabled) and sends back the masked payload, the serializer will validate and save the literal string "write-only" as the new personal access token, destroying the actual token in the database.

To prevent this, we must override validate_personal_access_token to check if the incoming value is the placeholder "write-only". If it is, and we are updating an existing instance, we should preserve the existing token.

from typing import Any

from rest_framework.exceptions import ValidationError

from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.common.serializers import BaseProjectIntegrationModelSerializer

WRITE_ONLY_PLACEHOLDER = "write-only"


class AzureDevOpsConfigurationSerializer(BaseProjectIntegrationModelSerializer):
    class Meta:
        model = AzureDevOpsConfiguration
        fields = (
            "id",
            "organisation_url",
            "personal_access_token",
            "labeling_enabled",
            "tagging_enabled",
        )

    def validate_personal_access_token(self, value: str) -> str:
        if value == WRITE_ONLY_PLACEHOLDER:
            if self.instance:
                return self.instance.personal_access_token
            raise ValidationError("Personal access token cannot be 'write-only'.")
        return value

    def to_representation(self, instance: AzureDevOpsConfiguration) -> dict[str, Any]:
        data = super().to_representation(instance)
        data["personal_access_token"] = WRITE_ONLY_PLACEHOLDER
        return data

5 changes: 5 additions & 0 deletions api/integrations/azure_devops/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from integrations.azure_devops.views.configuration import (
AzureDevOpsConfigurationViewSet,
)

__all__ = ["AzureDevOpsConfigurationViewSet"]
43 changes: 43 additions & 0 deletions api/integrations/azure_devops/views/configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import structlog
from structlog.typing import FilteringBoundLogger

from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.azure_devops.serializers import (
AzureDevOpsConfigurationSerializer,
)
from integrations.common.views import ProjectIntegrationBaseViewSet

logger = structlog.get_logger("azure_devops")


class AzureDevOpsConfigurationViewSet(ProjectIntegrationBaseViewSet):
serializer_class = AzureDevOpsConfigurationSerializer # type: ignore[assignment]
model_class = AzureDevOpsConfiguration # type: ignore[assignment]
pagination_class = None

def _log_for(self, config: AzureDevOpsConfiguration) -> FilteringBoundLogger:
return logger.bind( # type: ignore[no-any-return]
organisation__id=config.project.organisation_id,
project__id=config.project.id,
)

def perform_create(self, serializer: AzureDevOpsConfigurationSerializer) -> None: # type: ignore[override]
super().perform_create(serializer)
instance: AzureDevOpsConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
"configuration.created",
ado__organisation__url=instance.organisation_url,
)

def perform_update(self, serializer: AzureDevOpsConfigurationSerializer) -> None: # type: ignore[override]
super().perform_update(serializer)
instance: AzureDevOpsConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
"configuration.updated",
ado__organisation__url=instance.organisation_url,
)

def perform_destroy(self, instance: AzureDevOpsConfiguration) -> None:
log = self._log_for(instance)
super().perform_destroy(instance)
log.info("configuration.deleted")
6 changes: 6 additions & 0 deletions api/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from features.multivariate.views import MultivariateFeatureOptionViewSet
from features.views import FeatureViewSet
from integrations.azure_devops.views import AzureDevOpsConfigurationViewSet
from integrations.datadog.views import DataDogConfigurationViewSet
from integrations.gitlab.views import (
BrowseGitLabIssues,
Expand Down Expand Up @@ -76,6 +77,11 @@
GitLabConfigurationViewSet,
basename="integrations-gitlab",
)
projects_router.register(
r"integrations/azure-devops",
AzureDevOpsConfigurationViewSet,
basename="integrations-azure-devops",
)
projects_router.register(
r"integrations/grafana",
GrafanaProjectConfigurationViewSet,
Expand Down
Empty file.
13 changes: 13 additions & 0 deletions api/tests/unit/integrations/azure_devops/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest

from integrations.azure_devops.models import AzureDevOpsConfiguration
from projects.models import Project


@pytest.fixture()
def azure_devops_configuration(project: Project) -> AzureDevOpsConfiguration:
return AzureDevOpsConfiguration.objects.create( # type: ignore[no-any-return]
project=project,
organisation_url="https://dev.azure.com/test-org",
personal_access_token="ado-test-token",
)
12 changes: 12 additions & 0 deletions api/tests/unit/integrations/azure_devops/test_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.apps import apps


def test_azure_devops_app__django_registry__contains_config() -> None:
# Given
app_label = "azure_devops"

# When
config = apps.get_app_config(app_label)

# Then
assert config.name == "integrations.azure_devops"
Loading
Loading