mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
feat: record the tenant profile declared at onboarding (#12818)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9f616a5d43
commit
3069486549
@@ -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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user