feat(provider-groups): PRWLR-4725 add provider-groups system (#82)

* feat(provider-groups): PRWLR-4725 add provider-groups system

* feat(provider-groups): PRWLR-4725 add provider-groups migrations

* feat(provider-groups): PRWLR-4725 improve provider-groups models
This commit is contained in:
Adrián Jesús Peña Rodríguez
2024-11-13 18:17:08 +01:00
committed by GitHub
parent f3dce4f7a7
commit bf04261af6
10 changed files with 1204 additions and 2 deletions
+15
View File
@@ -25,6 +25,7 @@ from api.models import (
User,
Membership,
Provider,
ProviderGroup,
Resource,
ResourceTag,
Scan,
@@ -136,6 +137,20 @@ class ProviderRelationshipFilterSet(FilterSet):
)
class ProviderGroupFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
class Meta:
model = ProviderGroup
fields = {
"id": ["exact", "in"],
"name": ["exact", "in"],
"inserted_at": ["gte", "lte"],
"updated_at": ["gte", "lte"],
}
class ScanFilter(ProviderRelationshipFilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
completed_at = DateFilter(field_name="completed_at", lookup_expr="date")
+62
View File
@@ -0,0 +1,62 @@
[
{
"model": "api.providergroup",
"pk": "3fe28fb8-e545-424c-9b8f-69aff638f430",
"fields": {
"name": "first_group",
"inserted_at": "2024-11-13T11:36:19.503Z",
"updated_at": "2024-11-13T11:36:19.503Z",
"tenant": "12646005-9067-4d2a-a098-8bb378604362"
}
},
{
"model": "api.providergroup",
"pk": "525e91e7-f3f3-4254-bbc3-27ce1ade86b1",
"fields": {
"name": "second_group",
"inserted_at": "2024-11-13T11:36:25.421Z",
"updated_at": "2024-11-13T11:36:25.421Z",
"tenant": "12646005-9067-4d2a-a098-8bb378604362"
}
},
{
"model": "api.providergroup",
"pk": "481769f5-db2b-447b-8b00-1dee18db90ec",
"fields": {
"name": "third_group",
"inserted_at": "2024-11-13T11:36:37.603Z",
"updated_at": "2024-11-13T11:36:37.603Z",
"tenant": "12646005-9067-4d2a-a098-8bb378604362"
}
},
{
"model": "api.providergroupmembership",
"pk": "13625bd3-f428-4021-ac1b-b0bd41b6e02f",
"fields": {
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"provider": "1b59e032-3eb6-4694-93a5-df84cd9b3ce2",
"provider_group": "3fe28fb8-e545-424c-9b8f-69aff638f430",
"inserted_at": "2024-11-13T11:55:17.138Z"
}
},
{
"model": "api.providergroupmembership",
"pk": "54784ebe-42d2-4937-aa6a-e21c62879567",
"fields": {
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"provider": "15fce1fa-ecaa-433f-a9dc-62553f3a2555",
"provider_group": "3fe28fb8-e545-424c-9b8f-69aff638f430",
"inserted_at": "2024-11-13T11:55:17.138Z"
}
},
{
"model": "api.providergroupmembership",
"pk": "c8bd52d5-42a5-48fe-8e0a-3eef154b8ebe",
"fields": {
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"provider": "15fce1fa-ecaa-433f-a9dc-62553f3a2555",
"provider_group": "525e91e7-f3f3-4254-bbc3-27ce1ade86b1",
"inserted_at": "2024-11-13T11:55:41.237Z"
}
}
]
+105
View File
@@ -448,6 +448,111 @@ class Migration(migrations.Migration):
name="unique_provider_uids",
),
),
migrations.CreateModel(
name="ProviderGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.CharField(max_length=255)),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "provider_groups",
},
),
migrations.CreateModel(
name="ProviderGroupMembership",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
],
options={
"db_table": "provider_group_memberships",
},
),
migrations.AddField(
model_name="providergroup",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="api.tenant",
),
),
migrations.AddField(
model_name="providergroup",
name="providers",
field=models.ManyToManyField(
related_name="provider_groups",
through="api.ProviderGroupMembership",
to="api.provider",
),
),
migrations.AddField(
model_name="providergroupmembership",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddField(
model_name="providergroupmembership",
name="provider",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.provider"
),
),
migrations.AddField(
model_name="providergroupmembership",
name="provider_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.providergroup"
),
),
migrations.AddConstraint(
model_name="providergroup",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_providergroup",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="providergroup",
constraint=models.UniqueConstraint(
fields=("tenant_id", "name"), name="unique_group_name_per_tenant"
),
),
migrations.AddConstraint(
model_name="providergroupmembership",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_providergroupmembership",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="providergroupmembership",
constraint=models.UniqueConstraint(
fields=("provider_id", "provider_group"),
name="unique_provider_group_membership",
),
),
migrations.CreateModel(
name="Task",
fields=[
+57
View File
@@ -235,6 +235,63 @@ class Provider(RowLevelSecurityProtectedModel):
]
class ProviderGroup(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=255)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
providers = models.ManyToManyField(
Provider, through="ProviderGroupMembership", related_name="provider_groups"
)
class Meta:
db_table = "provider_groups"
constraints = [
models.UniqueConstraint(
fields=["tenant_id", "name"],
name="unique_group_name_per_tenant",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "provider-groups"
class ProviderGroupMembership(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
provider = models.ForeignKey(
Provider,
on_delete=models.CASCADE,
)
provider_group = models.ForeignKey(
ProviderGroup,
on_delete=models.CASCADE,
)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
class Meta:
db_table = "provider_group_memberships"
constraints = [
models.UniqueConstraint(
fields=["provider_id", "provider_group"],
name="unique_provider_group_membership",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "provider-group-memberships"
class Task(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+504 -1
View File
@@ -539,6 +539,296 @@ paths:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/provider_groups:
get:
operationId: provider_groups_list
description: Retrieve a list of all provider groups with options for filtering
by various criteria.
summary: List all provider groups
parameters:
- in: query
name: fields[provider-groups]
schema:
type: array
items:
type: string
enum:
- name
- inserted_at
- updated_at
- providers
- url
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: query
name: filter[id]
schema:
type: string
format: uuid
- in: query
name: filter[id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[inserted_at]
schema:
type: string
format: date
- in: query
name: filter[inserted_at__gte]
schema:
type: string
format: date-time
- in: query
name: filter[inserted_at__lte]
schema:
type: string
format: date-time
- in: query
name: filter[name]
schema:
type: string
- in: query
name: filter[name__in]
schema:
type: array
items:
type: string
description: Multiple values may be separated by commas.
explode: false
style: form
- name: filter[search]
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: filter[updated_at]
schema:
type: string
format: date
- in: query
name: filter[updated_at__gte]
schema:
type: string
format: date-time
- in: query
name: filter[updated_at__lte]
schema:
type: string
format: date-time
- name: page[number]
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page[size]
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: sort
required: false
in: query
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
schema:
type: array
items:
type: string
enum:
- id
- -id
- name
- -name
- inserted_at
- -inserted_at
- updated_at
- -updated_at
- providers
- -providers
- url
- -url
explode: false
tags:
- Provider Group
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedProviderGroupList'
description: ''
post:
operationId: provider_groups_create
description: Add a new provider group to the system by providing the required
provider group details.
summary: Create a new provider group
tags:
- Provider Group
requestBody:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ProviderGroupRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/ProviderGroupRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/ProviderGroupRequest'
required: true
security:
- jwtAuth: []
responses:
'201':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ProviderGroupResponse'
description: ''
/api/v1/provider_groups/{id}:
get:
operationId: provider_groups_retrieve
description: Fetch detailed information about a specific provider group by their
ID.
summary: Retrieve data from a provider group
parameters:
- in: query
name: fields[provider-groups]
schema:
type: array
items:
type: string
enum:
- name
- inserted_at
- updated_at
- providers
- url
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this provider group.
required: true
tags:
- Provider Group
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ProviderGroupResponse'
description: ''
patch:
operationId: provider_groups_partial_update
description: Update certain fields of an existing provider group's information
without affecting other fields.
summary: Partially update a provider group
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this provider group.
required: true
tags:
- Provider Group
requestBody:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PatchedProviderGroupUpdateRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PatchedProviderGroupUpdateRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/PatchedProviderGroupUpdateRequest'
required: true
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/SerializerMetaclassResponse'
description: ''
delete:
operationId: provider_groups_destroy
description: Remove a provider group from the system by their ID.
summary: Delete a provider group
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this provider group.
required: true
tags:
- Provider Group
security:
- jwtAuth: []
responses:
'204':
description: No response body
/api/v1/provider_groups/{id}/providers:
put:
operationId: provider_groups_providers_update
description: Add one or more providers to an existing provider group.
summary: Add providers to a provider group
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this provider group.
required: true
tags:
- Provider Group
requestBody:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
required: true
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/providers:
get:
operationId: providers_list
@@ -3649,6 +3939,15 @@ components:
$ref: '#/components/schemas/Membership'
required:
- data
PaginatedProviderGroupList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ProviderGroup'
required:
- data
PaginatedProviderList:
type: object
properties:
@@ -3762,6 +4061,37 @@ components:
minLength: 1
required:
- data
PatchedProviderGroupUpdateRequest:
type: object
properties:
data:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- provider-groups
id:
type: string
format: uuid
attributes:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 255
required:
- name
required:
- data
PatchedProviderSecretUpdateRequest:
type: object
properties:
@@ -4300,6 +4630,177 @@ components:
$ref: '#/components/schemas/ProviderCreate'
required:
- data
ProviderGroup:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/ProviderGroupTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
attributes:
type: object
properties:
name:
type: string
maxLength: 255
inserted_at:
type: string
format: date-time
readOnly: true
updated_at:
type: string
format: date-time
readOnly: true
required:
- name
relationships:
type: object
properties:
providers:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
title: Resource Identifier
description: The identifier of the related object.
type:
type: string
enum:
- Provider
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
required:
- data
description: A related resource object from type Provider
title: Provider
readOnly: true
ProviderGroupMembershipUpdateRequest:
type: object
properties:
data:
type: object
required:
- type
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- provider-group-memberships
attributes:
type: object
properties:
provider_ids:
type: array
items:
type: string
format: uuid
description: List of provider UUIDs to add to the group
required:
- provider_ids
required:
- data
ProviderGroupRequest:
type: object
properties:
data:
type: object
required:
- type
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- provider-groups
attributes:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 255
inserted_at:
type: string
format: date-time
readOnly: true
updated_at:
type: string
format: date-time
readOnly: true
required:
- name
relationships:
type: object
properties:
providers:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
title: Resource Identifier
description: The identifier of the related object.
type:
type: string
enum:
- Provider
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share
common attributes and relationships.
required:
- id
- type
required:
- data
description: A related resource object from type Provider
title: Provider
readOnly: true
required:
- data
ProviderGroupResponse:
type: object
properties:
data:
$ref: '#/components/schemas/ProviderGroup'
required:
- data
ProviderGroupTypeEnum:
type: string
enum:
- provider-groups
ProviderResponse:
type: object
properties:
@@ -5291,7 +5792,7 @@ components:
type: object
properties:
data:
$ref: '#/components/schemas/Provider'
$ref: '#/components/schemas/ProviderGroup'
required:
- data
Task:
@@ -5867,6 +6368,8 @@ tags:
description: Endpoints for managing tenants, along with their memberships.
- name: Provider
description: Endpoints for managing providers (AWS, GCP, Azure, etc...).
- name: Provider Group
description: Endpoints for managing provider groups.
- name: Scan
description: Endpoints for triggering manual scans and viewing scan results.
- name: Resource
+254 -1
View File
@@ -8,7 +8,16 @@ import pytest
from django.urls import reverse
from rest_framework import status
from api.models import User, Membership, Provider, Scan, ProviderSecret, Invitation
from api.models import (
User,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
ProviderSecret,
Invitation,
)
from api.rls import Tenant
from conftest import (
API_JSON_CONTENT_TYPE,
@@ -1123,6 +1132,250 @@ class TestProviderViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestProviderGroupViewSet:
def test_provider_group_list(self, authenticated_client, provider_groups_fixture):
response = authenticated_client.get(reverse("providergroup-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(provider_groups_fixture)
def test_provider_group_retrieve(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[0]
response = authenticated_client.get(
reverse("providergroup-detail", kwargs={"pk": provider_group.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["id"] == str(provider_group.id)
assert data["attributes"]["name"] == provider_group.name
def test_provider_group_create(self, authenticated_client):
data = {
"data": {
"type": "provider-groups",
"attributes": {
"name": "Test Provider Group",
},
}
}
response = authenticated_client.post(
reverse("providergroup-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()["data"]
assert response_data["attributes"]["name"] == "Test Provider Group"
assert ProviderGroup.objects.filter(name="Test Provider Group").exists()
def test_provider_group_create_invalid(self, authenticated_client):
data = {
"data": {
"type": "provider-groups",
"attributes": {
# Name is missing
},
}
}
response = authenticated_client.post(
reverse("providergroup-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert errors[0]["source"]["pointer"] == "/data/attributes/name"
def test_provider_group_partial_update(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[1]
data = {
"data": {
"id": str(provider_group.id),
"type": "provider-groups",
"attributes": {
"name": "Updated Provider Group Name",
},
}
}
response = authenticated_client.patch(
reverse("providergroup-detail", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_group.refresh_from_db()
assert provider_group.name == "Updated Provider Group Name"
def test_provider_group_partial_update_invalid(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[2]
data = {
"data": {
"id": str(provider_group.id),
"type": "provider-groups",
"attributes": {
"name": "", # Invalid name
},
}
}
response = authenticated_client.patch(
reverse("providergroup-detail", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert errors[0]["source"]["pointer"] == "/data/attributes/name"
def test_provider_group_destroy(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[2]
response = authenticated_client.delete(
reverse("providergroup-detail", kwargs={"pk": provider_group.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not ProviderGroup.objects.filter(id=provider_group.id).exists()
def test_provider_group_destroy_invalid(self, authenticated_client):
response = authenticated_client.delete(
reverse("providergroup-detail", kwargs={"pk": "non-existent-id"})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_provider_group_providers_update(
self, authenticated_client, provider_groups_fixture, providers_fixture
):
provider_group = provider_groups_fixture[0]
provider_ids = [str(provider.id) for provider in providers_fixture]
data = {
"data": {
"type": "provider-group-memberships",
"id": str(provider_group.id),
"attributes": {"provider_ids": provider_ids},
}
}
response = authenticated_client.put(
reverse("providergroup-providers", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
memberships = ProviderGroupMembership.objects.filter(
provider_group=provider_group
)
assert memberships.count() == len(provider_ids)
for membership in memberships:
assert str(membership.provider_id) in provider_ids
def test_provider_group_providers_update_non_existent_provider(
self, authenticated_client, provider_groups_fixture, providers_fixture
):
provider_group = provider_groups_fixture[0]
provider_ids = [str(provider.id) for provider in providers_fixture]
provider_ids[-1] = "1b59e032-3eb6-4694-93a5-df84cd9b3ce2"
data = {
"data": {
"type": "provider-group-memberships",
"id": str(provider_group.id),
"attributes": {"provider_ids": provider_ids},
}
}
response = authenticated_client.put(
reverse("providergroup-providers", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert (
errors[0]["detail"]
== f"The following provider IDs do not exist: {provider_ids[-1]}"
)
def test_provider_group_providers_update_invalid_provider(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[1]
invalid_provider_id = "non-existent-id"
data = {
"data": {
"type": "provider-group-memberships",
"id": str(provider_group.id),
"attributes": {"provider_ids": [invalid_provider_id]},
}
}
response = authenticated_client.put(
reverse("providergroup-providers", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert errors[0]["detail"] == "Must be a valid UUID."
def test_provider_group_providers_update_invalid_payload(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[2]
data = {
# Missing "provider_ids"
}
response = authenticated_client.put(
reverse("providergroup-providers", kwargs={"pk": provider_group.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert errors[0]["detail"] == "Received document does not contain primary data"
def test_provider_group_retrieve_not_found(self, authenticated_client):
response = authenticated_client.get(
reverse("providergroup-detail", kwargs={"pk": "non-existent-id"})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_provider_group_list_filters(
self, authenticated_client, provider_groups_fixture
):
provider_group = provider_groups_fixture[0]
response = authenticated_client.get(
reverse("providergroup-list"), {"filter[name]": provider_group.name}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
assert data[0]["attributes"]["name"] == provider_group.name
def test_provider_group_list_sorting(
self, authenticated_client, provider_groups_fixture
):
response = authenticated_client.get(
reverse("providergroup-list"), {"sort": "name"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
names = [item["attributes"]["name"] for item in data]
assert names == sorted(names)
def test_provider_group_invalid_method(self, authenticated_client):
response = authenticated_client.put(reverse("providergroup-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@pytest.mark.django_db
class TestProviderSecretViewSet:
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
+83
View File
@@ -18,6 +18,8 @@ from api.models import (
User,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
@@ -354,6 +356,87 @@ class MembershipSerializer(serializers.ModelSerializer):
fields = ["id", "user", "tenant", "role", "date_joined"]
# Provider Groups
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
providers = serializers.ResourceRelatedField(many=True, read_only=True)
def validate(self, attrs):
tenant = self.context["tenant_id"]
name = attrs.get("name", self.instance.name if self.instance else None)
# Exclude the current instance when checking for uniqueness during updates
queryset = ProviderGroup.objects.filter(tenant=tenant, name=name)
if self.instance:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise serializers.ValidationError(
{
"name": "A provider group with this name already exists for this tenant."
}
)
return super().validate(attrs)
class Meta:
model = ProviderGroup
fields = ["id", "name", "inserted_at", "updated_at", "providers", "url"]
read_only_fields = ["id", "inserted_at", "updated_at"]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
}
class ProviderGroupUpdateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for updating the ProviderGroup model.
Only allows "name" field to be updated.
"""
class Meta:
model = ProviderGroup
fields = ["id", "name"]
class ProviderGroupMembershipUpdateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for modifying provider group memberships
"""
provider_ids = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of provider UUIDs to add to the group",
)
def validate(self, attrs):
tenant_id = self.context["tenant_id"]
provider_ids = attrs.get("provider_ids", [])
existing_provider_ids = set(
Provider.objects.filter(
id__in=provider_ids, tenant_id=tenant_id
).values_list("id", flat=True)
)
provided_provider_ids = set(provider_ids)
missing_provider_ids = provided_provider_ids - existing_provider_ids
if missing_provider_ids:
raise serializers.ValidationError(
{
"provider_ids": f"The following provider IDs do not exist: {', '.join(str(id) for id in missing_provider_ids)}"
}
)
return super().validate(attrs)
class Meta:
model = ProviderGroupMembership
fields = ["id", "provider_ids"]
# Providers
class ProviderEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
+2
View File
@@ -15,6 +15,7 @@ from api.v1.views import (
TaskViewSet,
ResourceViewSet,
FindingViewSet,
ProviderGroupViewSet,
ProviderSecretViewSet,
InvitationViewSet,
InvitationAcceptViewSet,
@@ -25,6 +26,7 @@ router = routers.DefaultRouter(trailing_slash=False)
router.register(r"users", UserViewSet, basename="user")
router.register(r"tenants", TenantViewSet, basename="tenant")
router.register(r"providers", ProviderViewSet, basename="provider")
router.register(r"provider_groups", ProviderGroupViewSet, basename="providergroup")
router.register(r"scans", ScanViewSet, basename="scan")
router.register(r"tasks", TaskViewSet, basename="task")
router.register(r"resources", ResourceViewSet, basename="resource")
+102
View File
@@ -33,6 +33,7 @@ from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseUserViewset
from api.db_router import MainRouter
from api.filters import (
ProviderFilter,
ProviderGroupFilter,
TenantFilter,
MembershipFilter,
ScanFilter,
@@ -47,6 +48,8 @@ from api.models import (
User,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
@@ -64,6 +67,9 @@ from api.v1.serializers import (
UserCreateSerializer,
UserUpdateSerializer,
MembershipSerializer,
ProviderGroupSerializer,
ProviderGroupUpdateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderSerializer,
ProviderCreateSerializer,
ProviderUpdateSerializer,
@@ -168,6 +174,10 @@ class SchemaView(SpectacularAPIView):
"name": "Provider",
"description": "Endpoints for managing providers (AWS, GCP, Azure, etc...).",
},
{
"name": "Provider Group",
"description": "Endpoints for managing provider groups.",
},
{
"name": "Scan",
"description": "Endpoints for triggering manual scans and viewing scan results.",
@@ -466,6 +476,98 @@ class TenantMembersViewSet(BaseTenantViewset):
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(tags=["Provider Group"])
@extend_schema_view(
list=extend_schema(
summary="List all provider groups",
description="Retrieve a list of all provider groups with options for filtering by various criteria.",
),
retrieve=extend_schema(
summary="Retrieve data from a provider group",
description="Fetch detailed information about a specific provider group by their ID.",
),
create=extend_schema(
summary="Create a new provider group",
description="Add a new provider group to the system by providing the required provider group details.",
),
partial_update=extend_schema(
summary="Partially update a provider group",
description="Update certain fields of an existing provider group's information without affecting other fields.",
request=ProviderGroupUpdateSerializer,
responses={200: ProviderGroupSerializer},
),
destroy=extend_schema(
summary="Delete a provider group",
description="Remove a provider group from the system by their ID.",
),
update=extend_schema(exclude=True),
)
class ProviderGroupViewSet(BaseRLSViewSet):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupSerializer
filterset_class = ProviderGroupFilter
http_method_names = ["get", "post", "patch", "put", "delete"]
ordering = ["inserted_at"]
def get_queryset(self):
return ProviderGroup.objects.prefetch_related("providers")
def get_serializer_class(self):
if self.action == "partial_update":
return ProviderGroupUpdateSerializer
elif self.action == "providers":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ProviderGroupMembershipUpdateSerializer
return super().get_serializer_class()
@extend_schema(
tags=["Provider Group"],
summary="Add providers to a provider group",
description="Add one or more providers to an existing provider group.",
request=ProviderGroupMembershipUpdateSerializer,
responses={200: OpenApiResponse(response=ProviderGroupSerializer)},
)
@action(detail=True, methods=["put"], url_name="providers")
def providers(self, request, pk=None):
provider_group = self.get_object()
# Validate input data
serializer = self.get_serializer_class()(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
provider_ids = serializer.validated_data["provider_ids"]
# Update memberships
ProviderGroupMembership.objects.filter(
provider_group=provider_group, tenant_id=request.tenant_id
).delete()
provider_group_memberships = [
ProviderGroupMembership(
tenant_id=self.request.tenant_id,
provider_group=provider_group,
provider_id=provider_id,
)
for provider_id in provider_ids
]
ProviderGroupMembership.objects.bulk_create(
provider_group_memberships, ignore_conflicts=True
)
# Return the updated provider group with providers
provider_group.refresh_from_db()
self.response_serializer_class = ProviderGroupSerializer
response_serializer = ProviderGroupSerializer(
provider_group, context=self.get_serializer_context()
)
return Response(data=response_serializer.data, status=status.HTTP_200_OK)
@extend_schema_view(
list=extend_schema(
summary="List all providers",
+20
View File
@@ -17,6 +17,7 @@ from api.models import (
from api.models import (
User,
Provider,
ProviderGroup,
Resource,
ResourceTag,
Scan,
@@ -189,6 +190,25 @@ def providers_fixture(tenants_fixture):
return provider1, provider2, provider3, provider4, provider5
@pytest.fixture
def provider_groups_fixture(tenants_fixture):
tenant, *_ = tenants_fixture
pgroup1 = ProviderGroup.objects.create(
name="Group One",
tenant_id=tenant.id,
)
pgroup2 = ProviderGroup.objects.create(
name="Group Two",
tenant_id=tenant.id,
)
pgroup3 = ProviderGroup.objects.create(
name="Group Three",
tenant_id=tenant.id,
)
return pgroup1, pgroup2, pgroup3
@pytest.fixture
def provider_secret_fixture(providers_fixture):
return tuple(