feat: record the tenant profile declared at onboarding (#12818)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Bailo
2026-09-17 10:03:59 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9f616a5d43
commit 3069486549
23 changed files with 1844 additions and 5 deletions
+7
View File
@@ -41,6 +41,7 @@ from api.models import (
StatusChoices,
Task,
TenantAPIKey,
TenantOnboardingProfile,
ThreatScoreSnapshot,
User,
)
@@ -1788,6 +1789,12 @@ class LighthouseProviderModelsFilter(FilterSet):
}
class TenantOnboardingProfileFilter(FilterSet):
class Meta:
model = TenantOnboardingProfile
fields = {"skipped": ["exact"]}
class MuteRuleFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
@@ -0,0 +1,116 @@
import uuid
import api.rls
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0097_attack_paths_scan_db_defaults"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TenantOnboardingProfile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"declared_cloud_accounts",
models.CharField(
blank=True,
choices=[
("1", "1"),
("2-10", "2-10"),
("11-50", "11-50"),
("51-200", "51-200"),
("200+", "200+"),
],
max_length=16,
null=True,
),
),
(
"declared_role",
models.CharField(
blank=True,
choices=[
("security", "Security"),
("devops_platform", "DevOps / Platform"),
("developer", "Developer"),
("compliance_grc", "Compliance / GRC"),
("other", "Other"),
],
max_length=32,
null=True,
),
),
(
"declared_seniority",
models.CharField(
blank=True,
choices=[
("practitioner", "Practitioner / IC"),
("lead", "Team lead / Manager"),
("director", "Director / Head of"),
("executive", "VP / C-level"),
("founder", "Founder / Owner"),
],
max_length=32,
null=True,
),
),
("skipped", models.BooleanField(default=False)),
(
"submitted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_onboarding_profiles",
related_query_name="tenant_onboarding_profile",
to=settings.AUTH_USER_MODEL,
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "tenant_onboarding_profiles",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
constraint=models.UniqueConstraint(
fields=("tenant_id",), name="unique_tenant_onboarding_profile"
),
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
# `statements` written out explicitly: RowLevelSecurityConstraint
# .deconstruct() does not serialize it, so an autogenerated
# migration falls back to ["SELECT"] and leaves the table without
# INSERT/UPDATE/DELETE policies.
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_tenantonboardingprofile",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
+81
View File
@@ -3113,3 +3113,84 @@ class TenantComplianceSummary(RowLevelSecurityProtectedModel):
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class TenantOnboardingProfile(RowLevelSecurityProtectedModel):
"""What a tenant declared about itself at first login, before the product
shaped its behaviour.
One row per tenant. The three buckets are closed choices asked in the
onboarding profile step; ``skipped`` records that the step was shown and
dismissed, so a skip is a fact and not the absence of one.
``declared_role`` and ``declared_seniority`` are deliberately orthogonal:
the first is the discipline the person works in, the second how far up the
organisation they sit. Kept apart, "a security engineer" and "the CISO"
are two segments rather than one blurred bucket.
Immutable once written: a second submission returns the existing row so the
first answer, given before any product signal could bias it, is the one
that stays.
"""
class CloudAccountsBucket(models.TextChoices):
ONE = "1", _("1")
TWO_TO_TEN = "2-10", _("2-10")
ELEVEN_TO_FIFTY = "11-50", _("11-50")
FIFTY_ONE_TO_TWO_HUNDRED = "51-200", _("51-200")
OVER_TWO_HUNDRED = "200+", _("200+")
class Role(models.TextChoices):
SECURITY = "security", _("Security")
DEVOPS_PLATFORM = "devops_platform", _("DevOps / Platform")
DEVELOPER = "developer", _("Developer")
COMPLIANCE_GRC = "compliance_grc", _("Compliance / GRC")
OTHER = "other", _("Other")
class Seniority(models.TextChoices):
PRACTITIONER = "practitioner", _("Practitioner / IC")
LEAD = "lead", _("Team lead / Manager")
DIRECTOR = "director", _("Director / Head of")
EXECUTIVE = "executive", _("VP / C-level")
FOUNDER = "founder", _("Founder / Owner")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
declared_cloud_accounts = models.CharField(
max_length=16, choices=CloudAccountsBucket.choices, null=True, blank=True
)
declared_role = models.CharField(
max_length=32, choices=Role.choices, null=True, blank=True
)
declared_seniority = models.CharField(
max_length=32, choices=Seniority.choices, null=True, blank=True
)
skipped = models.BooleanField(default=False)
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tenant_onboarding_profiles",
related_query_name="tenant_onboarding_profile",
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "tenant_onboarding_profiles"
constraints = [
models.UniqueConstraint(
fields=["tenant_id"],
name="unique_tenant_onboarding_profile",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "onboarding-profiles"
def __str__(self) -> str:
return f"onboarding-profile:{self.tenant_id}"
@@ -0,0 +1,174 @@
import json
import pytest
from api.models import TenantOnboardingProfile
from conftest import API_JSON_CONTENT_TYPE
from django.urls import reverse
from rest_framework import status
ANSWERS = {
"declared_cloud_accounts": "11-50",
"declared_role": "security",
"declared_seniority": "director",
}
def _submit(client, attributes):
payload = {"data": {"type": "onboarding-profiles", "attributes": attributes}}
return client.post(
reverse("onboarding-profile-list"),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
def _error_pointers(response):
return [error["source"]["pointer"] for error in response.json()["errors"]]
@pytest.mark.django_db
class TestTenantOnboardingProfileViewSet:
def test_declares_the_profile_once(
self, authenticated_client, tenants_fixture, create_test_user
):
response = _submit(authenticated_client, ANSWERS)
assert response.status_code == status.HTTP_201_CREATED
attributes = response.json()["data"]["attributes"]
assert attributes["declared_cloud_accounts"] == "11-50"
assert attributes["declared_role"] == "security"
assert attributes["declared_seniority"] == "director"
assert attributes["skipped"] is False
profile = TenantOnboardingProfile.objects.get(tenant_id=tenants_fixture[0].id)
assert profile.submitted_by_id == create_test_user.id
def test_records_a_skip_as_a_fact(self, authenticated_client, tenants_fixture):
response = _submit(authenticated_client, {"skipped": True})
assert response.status_code == status.HTTP_201_CREATED
attributes = response.json()["data"]["attributes"]
assert attributes["skipped"] is True
assert attributes["declared_role"] is None
assert attributes["declared_seniority"] is None
assert TenantOnboardingProfile.objects.filter(
tenant_id=tenants_fixture[0].id, skipped=True
).exists()
def test_second_submission_keeps_the_first_answer(self, authenticated_client):
first = _submit(authenticated_client, ANSWERS)
second = _submit(
authenticated_client, {**ANSWERS, "declared_role": "developer"}
)
assert first.status_code == status.HTTP_201_CREATED
assert second.status_code == status.HTTP_200_OK
assert second.json()["data"]["id"] == first.json()["data"]["id"]
assert second.json()["data"]["attributes"]["declared_role"] == "security"
assert TenantOnboardingProfile.objects.count() == 1
def test_a_skip_cannot_replace_an_answer(self, authenticated_client):
_submit(authenticated_client, ANSWERS)
response = _submit(authenticated_client, {"skipped": True})
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["skipped"] is False
@pytest.mark.parametrize(
"attributes, pointer",
[
(
{"declared_cloud_accounts": "1"},
"/data/attributes/declared_role",
),
(
# Discipline without the ladder is still an incomplete profile.
{k: v for k, v in ANSWERS.items() if k != "declared_seniority"},
"/data/attributes/declared_seniority",
),
({}, "/data/attributes/declared_cloud_accounts"),
(
{"skipped": True, "declared_role": "developer"},
"/data/attributes/declared_role",
),
(
{**ANSWERS, "declared_cloud_accounts": "1000"},
"/data/attributes/declared_cloud_accounts",
),
(
{**ANSWERS, "declared_role": "ceo"},
"/data/attributes/declared_role",
),
(
# "management" moved out of the discipline list into the ladder.
{**ANSWERS, "declared_role": "management"},
"/data/attributes/declared_role",
),
(
{**ANSWERS, "declared_seniority": "intern"},
"/data/attributes/declared_seniority",
),
(
{"skipped": True, "declared_seniority": "founder"},
"/data/attributes/declared_seniority",
),
({**ANSWERS, "company": "Acme"}, "/data"),
],
)
def test_rejects_incomplete_or_unknown_answers(
self, authenticated_client, attributes, pointer
):
response = _submit(authenticated_client, attributes)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert pointer in _error_pointers(response)
assert not TenantOnboardingProfile.objects.exists()
def test_lists_only_the_tenant_profile(
self,
authenticated_client,
authenticated_client_for_tenant_factory,
create_test_user,
tenants_fixture,
):
other_client = authenticated_client_for_tenant_factory(
create_test_user, tenants_fixture[1]
)
_submit(authenticated_client, ANSWERS)
_submit(other_client, {"skipped": True})
own = authenticated_client.get(reverse("onboarding-profile-list"))
other = other_client.get(reverse("onboarding-profile-list"))
assert own.status_code == status.HTTP_200_OK
assert [entry["attributes"]["skipped"] for entry in own.json()["data"]] == [
False
]
assert [entry["attributes"]["skipped"] for entry in other.json()["data"]] == [
True
]
def test_empty_list_before_the_step_runs(self, authenticated_client):
response = authenticated_client.get(reverse("onboarding-profile-list"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"] == []
def test_members_without_permissions_can_answer(
self, authenticated_client_no_permissions_rbac
):
response = _submit(authenticated_client_no_permissions_rbac, ANSWERS)
assert response.status_code == status.HTTP_201_CREATED
def test_detail_route_is_refused(self, authenticated_client):
# One row per tenant, so the collection is the only meaningful read;
# the detail route must refuse rather than serve a second shape.
_submit(authenticated_client, ANSWERS)
profile = TenantOnboardingProfile.objects.get()
response = authenticated_client.get(
reverse("onboarding-profile-detail", kwargs={"pk": profile.id})
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+74
View File
@@ -35,6 +35,7 @@ from api.models import (
StatusChoices,
Task,
TenantAPIKey,
TenantOnboardingProfile,
ThreatScoreSnapshot,
User,
UserRoleRelationship,
@@ -4525,3 +4526,76 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
"uid": obj.get("provider_uid", ""),
"alias": obj.get("provider_alias", ""),
}
# Onboarding profile
DECLARED_PROFILE_FIELDS = (
"declared_cloud_accounts",
"declared_role",
"declared_seniority",
)
class TenantOnboardingProfileSerializer(RLSSerializer):
"""Read serializer for the tenant's declared onboarding profile."""
class Meta:
model = TenantOnboardingProfile
fields = [
"id",
"inserted_at",
*DECLARED_PROFILE_FIELDS,
"skipped",
]
read_only_fields = fields
class TenantOnboardingProfileCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""Record the profile step's outcome.
Either every answer is given, or ``skipped`` is true and none is: a skip
is recorded as such so "skipped" can be told from "never shown".
"""
declared_cloud_accounts = serializers.ChoiceField(
choices=TenantOnboardingProfile.CloudAccountsBucket.choices,
required=False,
allow_null=True,
)
declared_role = serializers.ChoiceField(
choices=TenantOnboardingProfile.Role.choices,
required=False,
allow_null=True,
)
declared_seniority = serializers.ChoiceField(
choices=TenantOnboardingProfile.Seniority.choices,
required=False,
allow_null=True,
)
skipped = serializers.BooleanField(required=False, default=False)
class Meta:
model = TenantOnboardingProfile
fields = ["id", *DECLARED_PROFILE_FIELDS, "skipped"]
extra_kwargs = {"id": {"read_only": True}}
class JSONAPIMeta:
resource_name = "onboarding-profiles"
def validate(self, data):
data = super().validate(data)
answers = {field: data.get(field) for field in DECLARED_PROFILE_FIELDS}
if data.get("skipped"):
answered = [field for field, value in answers.items() if value]
if answered:
raise ValidationError(
{answered[0]: "A skipped profile step carries no answers."}
)
return data
missing = [field for field, value in answers.items() if not value]
if missing:
raise ValidationError(
{missing[0]: "This field is required unless the step was skipped."}
)
return data
+6
View File
@@ -39,6 +39,7 @@ from api.v1.views import (
TenantApiKeyViewSet,
TenantFinishACSView,
TenantMembersViewSet,
TenantOnboardingProfileViewSet,
TenantViewSet,
UserRoleRelationshipView,
UserViewSet,
@@ -107,6 +108,11 @@ router.register(
basename="lighthouse-models",
)
router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule")
router.register(
r"onboarding-profiles",
TenantOnboardingProfileViewSet,
basename="onboarding-profile",
)
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+120 -1
View File
@@ -75,6 +75,7 @@ from api.filters import (
TaskFilter,
TenantApiKeyFilter,
TenantFilter,
TenantOnboardingProfileFilter,
ThreatScoreSnapshotFilter,
UserFilter,
)
@@ -119,6 +120,7 @@ from api.models import (
Task,
TenantAPIKey,
TenantComplianceSummary,
TenantOnboardingProfile,
ThreatScoreSnapshot,
User,
UserRoleRelationship,
@@ -231,6 +233,8 @@ from api.v1.serializers import (
TenantApiKeyCreateSerializer,
TenantApiKeySerializer,
TenantApiKeyUpdateSerializer,
TenantOnboardingProfileCreateSerializer,
TenantOnboardingProfileSerializer,
TenantSerializer,
ThreatScoreSnapshotSerializer,
TokenRefreshSerializer,
@@ -257,7 +261,7 @@ from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
from django.contrib.postgres.search import SearchQuery
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import (
BooleanField,
Case,
@@ -293,6 +297,7 @@ from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
extend_schema,
@@ -8935,3 +8940,117 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
return self._paginated_resource_response(
request, filtered_queryset, resource_ids, request.tenant_id
)
# Onboarding profile
@extend_schema_view(
list=extend_schema(
tags=["Onboarding"],
summary="Read the tenant's onboarding profile",
description=(
"The answers the tenant gave in the onboarding profile step, or "
"the record that the step was skipped. At most one entry per "
"tenant; an empty list means the step was never completed."
),
),
create=extend_schema(
tags=["Onboarding"],
summary="Record the tenant's onboarding profile",
description=(
"Store the declared cloud-account count, role and seniority, or "
"mark the step as skipped. Idempotent: once a "
"profile exists the endpoint answers 200 with the stored one and "
"never overwrites it, so the first answer, given before the "
"product could bias it, is the one that stays."
),
request=TenantOnboardingProfileCreateSerializer,
responses={
200: TenantOnboardingProfileSerializer,
201: TenantOnboardingProfileSerializer,
},
examples=[
OpenApiExample(
"DeclareProfile",
request_only=True,
media_type="application/vnd.api+json",
value={
"data": {
"type": "onboarding-profiles",
"attributes": {
"declared_cloud_accounts": "11-50",
"declared_role": "security",
"declared_seniority": "director",
},
}
},
),
OpenApiExample(
"SkipProfile",
request_only=True,
media_type="application/vnd.api+json",
value={
"data": {
"type": "onboarding-profiles",
"attributes": {"skipped": True},
}
},
),
],
),
retrieve=extend_schema(exclude=True),
)
class TenantOnboardingProfileViewSet(BaseRLSViewSet):
"""The tenant's declared onboarding profile: one row, written once."""
queryset = TenantOnboardingProfile.objects.all()
serializer_class = TenantOnboardingProfileSerializer
filterset_class = TenantOnboardingProfileFilter
http_method_names = ["get", "post"]
ordering = ["-inserted_at"]
ordering_fields = ["inserted_at"]
# Any member may answer: the step runs at first login, before roles are
# curated, and the answer describes the tenant rather than the user.
required_permissions = []
def set_required_permissions(self):
self.required_permissions = []
def get_queryset(self):
return TenantOnboardingProfile.objects.filter(tenant_id=self.request.tenant_id)
def get_serializer_class(self):
if self.action == "create":
return TenantOnboardingProfileCreateSerializer
return super().get_serializer_class()
def retrieve(self, request, *args, **kwargs):
# The resource is a single row per tenant, so the collection is the
# only meaningful read. `extend_schema(exclude=True)` hides the detail
# route from the docs; this is what actually closes it.
raise MethodNotAllowed(method="GET")
def _stored_response(self, profile, http_status):
return Response(
TenantOnboardingProfileSerializer(
profile, context=self.get_serializer_context()
).data,
status=http_status,
)
def create(self, request, *args, **kwargs):
existing = self.get_queryset().first()
if existing is not None:
return self._stored_response(existing, status.HTTP_200_OK)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
with transaction.atomic(using=self.db_alias):
profile = serializer.save(submitted_by=request.user)
except IntegrityError:
# Two first-login tabs raced on the unique tenant row; the answer
# that landed first is the one that stays.
return self._stored_response(
self.get_queryset().first(), status.HTTP_200_OK
)
return self._stored_response(profile, status.HTTP_201_CREATED)