Merge branch 'feat-rbac' into PRWLR-4669-Roles-Page-API-UI

This commit is contained in:
Pablo Lara
2024-12-10 09:29:24 +01:00
9 changed files with 687 additions and 454 deletions
@@ -117,8 +117,7 @@
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"role": "3f01e759-bdf9-4a99-8888-1ab805b79f93",
"provider_group": "3fe28fb8-e545-424c-9b8f-69aff638f430",
"inserted_at": "2024-11-20T15:32:42.402Z",
"updated_at": "2024-11-20T15:32:42.402Z"
"inserted_at": "2024-11-20T15:32:42.402Z"
}
},
{
@@ -128,8 +127,7 @@
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"role": "3f01e759-bdf9-4a99-8888-1ab805b79f93",
"provider_group": "481769f5-db2b-447b-8b00-1dee18db90ec",
"inserted_at": "2024-11-20T15:32:42.402Z",
"updated_at": "2024-11-20T15:32:42.402Z"
"inserted_at": "2024-11-20T15:32:42.402Z"
}
},
{
@@ -139,8 +137,7 @@
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"role": "3f01e759-bdf9-4a99-8888-1ab805b79f93",
"provider_group": "525e91e7-f3f3-4254-bbc3-27ce1ade86b1",
"inserted_at": "2024-11-20T15:32:42.402Z",
"updated_at": "2024-11-20T15:32:42.402Z"
"inserted_at": "2024-11-20T15:32:42.402Z"
}
},
{
@@ -150,8 +147,7 @@
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
"role": "3f01e759-bdf9-4a99-8888-1ab805b79f93",
"user": "8b38e2eb-6689-4f1e-a4ba-95b275130200",
"inserted_at": "2024-11-20T15:36:14.302Z",
"updated_at": "2024-11-20T15:36:14.302Z"
"inserted_at": "2024-11-20T15:36:14.302Z"
}
}
]
@@ -1407,239 +1407,6 @@ class Migration(migrations.Migration):
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.CreateModel(
name="Role",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.CharField(max_length=255)),
("manage_users", models.BooleanField(default=False)),
("manage_account", models.BooleanField(default=False)),
("manage_billing", models.BooleanField(default=False)),
("manage_providers", models.BooleanField(default=False)),
("manage_integrations", models.BooleanField(default=False)),
("manage_scans", models.BooleanField(default=False)),
("unlimited_visibility", models.BooleanField(default=False)),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "roles",
},
),
migrations.CreateModel(
name="RoleProviderGroupRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_provider_group_relationship",
},
),
migrations.CreateModel(
name="UserRoleRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_user_relationship",
},
),
migrations.AddField(
model_name="roleprovidergrouprelationship",
name="provider_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.providergroup"
),
),
migrations.AddField(
model_name="roleprovidergrouprelationship",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
migrations.AddField(
model_name="role",
name="provider_groups",
field=models.ManyToManyField(
related_name="roles",
through="api.RoleProviderGroupRelationship",
to="api.providergroup",
),
),
migrations.AddField(
model_name="userrolerelationship",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
migrations.AddField(
model_name="userrolerelationship",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.AddField(
model_name="role",
name="users",
field=models.ManyToManyField(
related_name="roles",
through="api.UserRoleRelationship",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="roleprovidergrouprelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "provider_group_id"),
name="unique_role_provider_group_relationship",
),
),
migrations.AddConstraint(
model_name="roleprovidergrouprelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_roleprovidergrouprelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="userrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "user_id"), name="unique_role_user_relationship"
),
),
migrations.AddConstraint(
model_name="userrolerelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_userrolerelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="role",
constraint=models.UniqueConstraint(
fields=("tenant_id", "name"), name="unique_role_per_tenant"
),
),
migrations.AddConstraint(
model_name="role",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_role",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.CreateModel(
name="InvitationRoleRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"invitation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.invitation"
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_invitation_relationship",
},
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "invitation_id"),
name="unique_role_invitation_relationship",
),
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_invitationrolerelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddField(
model_name="role",
name="invitations",
field=models.ManyToManyField(
related_name="roles",
through="api.InvitationRoleRelationship",
to="api.invitation",
),
),
migrations.CreateModel(
name="ComplianceOverview",
fields=[
+246
View File
@@ -0,0 +1,246 @@
# Generated by Django 5.1.1 on 2024-12-05 12:29
import api.rls
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0002_token_migrations"),
]
operations = [
migrations.CreateModel(
name="Role",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.CharField(max_length=255)),
("manage_users", models.BooleanField(default=False)),
("manage_account", models.BooleanField(default=False)),
("manage_billing", models.BooleanField(default=False)),
("manage_providers", models.BooleanField(default=False)),
("manage_integrations", models.BooleanField(default=False)),
("manage_scans", models.BooleanField(default=False)),
("unlimited_visibility", models.BooleanField(default=False)),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "roles",
},
),
migrations.CreateModel(
name="RoleProviderGroupRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_provider_group_relationship",
},
),
migrations.CreateModel(
name="UserRoleRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_user_relationship",
},
),
migrations.AddField(
model_name="roleprovidergrouprelationship",
name="provider_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.providergroup"
),
),
migrations.AddField(
model_name="roleprovidergrouprelationship",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
migrations.AddField(
model_name="role",
name="provider_groups",
field=models.ManyToManyField(
related_name="roles",
through="api.RoleProviderGroupRelationship",
to="api.providergroup",
),
),
migrations.AddField(
model_name="userrolerelationship",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
migrations.AddField(
model_name="userrolerelationship",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.AddField(
model_name="role",
name="users",
field=models.ManyToManyField(
related_name="roles",
through="api.UserRoleRelationship",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="roleprovidergrouprelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "provider_group_id"),
name="unique_role_provider_group_relationship",
),
),
migrations.AddConstraint(
model_name="roleprovidergrouprelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_roleprovidergrouprelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="userrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "user_id"), name="unique_role_user_relationship"
),
),
migrations.AddConstraint(
model_name="userrolerelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_userrolerelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="role",
constraint=models.UniqueConstraint(
fields=("tenant_id", "name"), name="unique_role_per_tenant"
),
),
migrations.AddConstraint(
model_name="role",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_role",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.CreateModel(
name="InvitationRoleRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"invitation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.invitation"
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_invitation_relationship",
},
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "invitation_id"),
name="unique_role_invitation_relationship",
),
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_invitationrolerelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddField(
model_name="role",
name="invitations",
field=models.ManyToManyField(
related_name="roles",
through="api.InvitationRoleRelationship",
to="api.invitation",
),
),
]
+4 -16
View File
@@ -298,19 +298,10 @@ class ProviderGroup(RowLevelSecurityProtectedModel):
class ProviderGroupMembership(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
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)
provider_group = models.ForeignKey(ProviderGroup, on_delete=models.CASCADE)
provider = models.ForeignKey(Provider, on_delete=models.CASCADE)
inserted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "provider_group_memberships"
@@ -327,7 +318,7 @@ class ProviderGroupMembership(RowLevelSecurityProtectedModel):
]
class JSONAPIMeta:
resource_name = "provider-group-memberships"
resource_name = "provider_groups-provider"
class Task(RowLevelSecurityProtectedModel):
@@ -896,7 +887,6 @@ class RoleProviderGroupRelationship(RowLevelSecurityProtectedModel):
role = models.ForeignKey(Role, on_delete=models.CASCADE)
provider_group = models.ForeignKey(ProviderGroup, on_delete=models.CASCADE)
inserted_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "role_provider_group_relationship"
@@ -921,7 +911,6 @@ class UserRoleRelationship(RowLevelSecurityProtectedModel):
role = models.ForeignKey(Role, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
inserted_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "role_user_relationship"
@@ -946,7 +935,6 @@ class InvitationRoleRelationship(RowLevelSecurityProtectedModel):
role = models.ForeignKey(Role, on_delete=models.CASCADE)
invitation = models.ForeignKey(Invitation, on_delete=models.CASCADE)
inserted_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "role_invitation_relationship"
+123 -35
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.0.0
version: 1.0.1
description: |-
Prowler API specification.
@@ -1551,7 +1551,7 @@ paths:
schema:
$ref: '#/components/schemas/OverviewProviderResponse'
description: ''
/api/v1/provider_groups:
/api/v1/provider-groups:
get:
operationId: provider_groups_list
description: Retrieve a list of all provider groups with options for filtering
@@ -1713,7 +1713,7 @@ paths:
schema:
$ref: '#/components/schemas/ProviderGroupResponse'
description: ''
/api/v1/provider_groups/{id}:
/api/v1/provider-groups/{id}:
get:
operationId: provider_groups_retrieve
description: Fetch detailed information about a specific provider group by their
@@ -1809,42 +1809,69 @@ paths:
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
/api/v1/provider-groups/{id}/relationships/providers:
post:
operationId: provider_groups_relationships_providers_create
description: Add a new provider_group-providers relationship to the system by
providing the required provider_group-providers details.
summary: Create a new provider_group-providers relationship
tags:
- Provider Group
requestBody:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
$ref: '#/components/schemas/ProviderGroupMembershipRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
$ref: '#/components/schemas/ProviderGroupMembershipRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/ProviderGroupMembershipUpdateRequest'
$ref: '#/components/schemas/ProviderGroupMembershipRequest'
required: true
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
'204':
description: Relationship created successfully
'400':
description: Bad request (e.g., relationship already exists)
patch:
operationId: provider_groups_relationships_providers_partial_update
description: Update the provider_group-providers relationship information without
affecting other fields.
summary: Partially update a provider_group-providers relationship
tags:
- Provider Group
requestBody:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PatchedProviderGroupMembershipRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PatchedProviderGroupMembershipRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/PatchedProviderGroupMembershipRequest'
required: true
security:
- jwtAuth: []
responses:
'204':
description: Relationship updated successfully
delete:
operationId: provider_groups_relationships_providers_destroy
description: Remove the provider_group-providers relationship from the system
by their ID.
summary: Delete a provider_group-providers relationship
tags:
- Provider Group
security:
- jwtAuth: []
responses:
'204':
description: Relationship deleted successfully
/api/v1/providers:
get:
operationId: providers_list
@@ -6214,6 +6241,38 @@ components:
- roles
required:
- data
PatchedProviderGroupMembershipRequest:
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-provider
id:
type: string
format: uuid
attributes:
type: object
properties:
providers:
type: array
items:
$ref: '#/components/schemas/ProviderResourceIdentifierRequest'
description: List of resource identifier objects representing providers.
required:
- providers
required:
- data
PatchedProviderGroupUpdateRequest:
type: object
properties:
@@ -6812,7 +6871,7 @@ components:
type:
type: string
enum:
- provider-groups
- provider-group
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
@@ -6822,8 +6881,8 @@ components:
- type
required:
- data
description: A related resource object from type provider-groups
title: provider-groups
description: A related resource object from type provider-group
title: provider-group
readOnly: true
required:
- secret
@@ -7011,7 +7070,7 @@ components:
description: A related resource object from type role
title: role
readOnly: true
ProviderGroupMembershipUpdateRequest:
ProviderGroupMembershipRequest:
type: object
properties:
data:
@@ -7026,18 +7085,17 @@ components:
member is used to describe resource objects that share common attributes
and relationships.
enum:
- provider-group-memberships
- provider_groups-provider
attributes:
type: object
properties:
provider_ids:
providers:
type: array
items:
type: string
format: uuid
description: List of provider UUIDs to add to the group
$ref: '#/components/schemas/ProviderResourceIdentifierRequest'
description: List of resource identifier objects representing providers.
required:
- provider_ids
- providers
required:
- data
ProviderGroupRequest:
@@ -7177,6 +7235,36 @@ components:
type: string
enum:
- provider-group
ProviderResourceIdentifierRequest:
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-identifier
attributes:
type: object
properties:
resource_type:
type: string
minLength: 1
id:
type: string
format: uuid
required:
- resource_type
- id
required:
- data
ProviderResponse:
type: object
properties:
+162 -94
View File
@@ -1305,100 +1305,6 @@ class TestProviderGroupViewSet:
)
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"})
@@ -3621,6 +3527,168 @@ class TestRoleProviderGroupRelationshipViewSet:
assert "valid UUID" in errors
@pytest.mark.django_db
class TestProviderGroupMembershipViewSet:
def test_create_relationship(
self, authenticated_client, providers_fixture, provider_groups_fixture
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
for provider in providers_fixture[:2]
]
}
response = authenticated_client.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
relationships = ProviderGroupMembership.objects.filter(
provider_group=provider_group.id
)
assert relationships.count() == 2
for relationship in relationships:
assert relationship.provider.id in [p.id for p in providers_fixture[:2]]
def test_create_relationship_already_exists(
self, authenticated_client, providers_fixture, provider_groups_fixture
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
for provider in providers_fixture[:2]
]
}
authenticated_client.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
data = {
"data": [
{"type": "provider", "id": str(providers_fixture[0].id)},
]
}
response = authenticated_client.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]["detail"]
assert "already associated" in errors
def test_partial_update_relationship(
self, authenticated_client, providers_fixture, provider_groups_fixture
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(providers_fixture[1].id)},
]
}
response = authenticated_client.patch(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
relationships = ProviderGroupMembership.objects.filter(
provider_group=provider_group.id
)
assert relationships.count() == 1
assert {rel.provider.id for rel in relationships} == {providers_fixture[1].id}
data = {
"data": [
{"type": "provider", "id": str(providers_fixture[1].id)},
{"type": "provider", "id": str(providers_fixture[2].id)},
]
}
response = authenticated_client.patch(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
relationships = ProviderGroupMembership.objects.filter(
provider_group=provider_group.id
)
assert relationships.count() == 2
assert {rel.provider.id for rel in relationships} == {
providers_fixture[1].id,
providers_fixture[2].id,
}
def test_destroy_relationship(
self, authenticated_client, providers_fixture, provider_groups_fixture
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
for provider in providers_fixture[:2]
]
}
response = authenticated_client.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
response = authenticated_client.delete(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
)
assert response.status_code == status.HTTP_204_NO_CONTENT
relationships = ProviderGroupMembership.objects.filter(
provider_group=providers_fixture[0].id
)
assert relationships.count() == 0
def test_invalid_provider_group_id(
self, authenticated_client, provider_groups_fixture
):
provider_group, *_ = provider_groups_fixture
invalid_id = "non-existent-id"
data = {"data": [{"type": "provider-group", "id": invalid_id}]}
response = authenticated_client.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": provider_group.id},
),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"][0]["detail"]
assert "valid UUID" in errors
@pytest.mark.django_db
class TestComplianceOverviewViewSet:
def test_compliance_overview_list_none(self, authenticated_client):
+58 -24
View File
@@ -492,41 +492,75 @@ class ProviderGroupUpdateSerializer(RLSSerializer, BaseWriteSerializer):
fields = ["id", "name"]
class ProviderGroupMembershipUpdateSerializer(RLSSerializer, BaseWriteSerializer):
class ProviderResourceIdentifierSerializer(serializers.Serializer):
resource_type = serializers.CharField(source="type")
id = serializers.UUIDField()
class JSONAPIMeta:
resource_name = "provider-identifier"
def to_representation(self, instance):
"""
Ensure 'type' is used in the output instead of 'resource_type'.
"""
representation = super().to_representation(instance)
representation["type"] = representation.pop("resource_type", None)
return representation
def to_internal_value(self, data):
"""
Map 'type' back to 'resource_type' during input.
"""
data["resource_type"] = data.pop("type", None)
return super().to_internal_value(data)
class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for modifying provider group memberships
Serializer for modifying provider_group memberships
"""
provider_ids = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of provider UUIDs to add to the group",
providers = serializers.ListField(
child=ProviderResourceIdentifierSerializer(),
help_text="List of resource identifier objects representing providers.",
)
def validate(self, attrs):
tenant_id = self.context["tenant_id"]
provider_ids = attrs.get("provider_ids", [])
def create(self, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
tenant_id = self.context.get("tenant_id")
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)}"
}
new_relationships = [
ProviderGroupMembership(
provider_group=self.context.get("provider_group"),
provider=p,
tenant_id=tenant_id,
)
for p in providers
]
ProviderGroupMembership.objects.bulk_create(new_relationships)
return super().validate(attrs)
return self.context.get("provider_group")
def update(self, instance, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
tenant_id = self.context.get("tenant_id")
instance.providers.clear()
new_relationships = [
ProviderGroupMembership(
provider_group=instance, provider=p, tenant_id=tenant_id
)
for p in providers
]
ProviderGroupMembership.objects.bulk_create(new_relationships)
return instance
class Meta:
model = ProviderGroupMembership
fields = ["id", "provider_ids"]
fields = ["id", "providers"]
# Providers
+9 -1
View File
@@ -8,6 +8,7 @@ from api.v1.views import (
FindingViewSet,
MembershipViewSet,
ProviderGroupViewSet,
ProviderGroupProvidersRelationshipView,
ProviderSecretViewSet,
InvitationViewSet,
InvitationAcceptViewSet,
@@ -32,7 +33,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"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")
@@ -98,6 +99,13 @@ urlpatterns = [
),
name="user-roles-relationship",
),
path(
"provider-groups/<uuid:pk>/relationships/providers",
ProviderGroupProvidersRelationshipView.as_view(
{"post": "create", "patch": "partial_update", "delete": "destroy"}
),
name="provider_group-providers-relationship",
),
path("", include(router.urls)),
path("", include(tenants_router.urls)),
path("", include(users_router.urls)),
+81 -43
View File
@@ -103,7 +103,7 @@ from api.v1.serializers import (
OverviewProviderSerializer,
OverviewSeveritySerializer,
ProviderCreateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderGroupMembershipSerializer,
ProviderGroupSerializer,
ProviderGroupUpdateSerializer,
RoleProviderGroupRelationshipSerializer,
@@ -716,7 +716,7 @@ class ProviderGroupViewSet(BaseRLSViewSet):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupSerializer
filterset_class = ProviderGroupFilter
http_method_names = ["get", "post", "patch", "put", "delete"]
http_method_names = ["get", "post", "patch", "delete"]
ordering = ["inserted_at"]
required_permissions = []
permission_classes = BaseRLSViewSet.permission_classes + [HasPermissions]
@@ -762,57 +762,95 @@ class ProviderGroupViewSet(BaseRLSViewSet):
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):
@extend_schema(tags=["Provider Group"])
@extend_schema_view(
create=extend_schema(
summary="Create a new provider_group-providers relationship",
description="Add a new provider_group-providers relationship to the system by providing the required provider_group-providers details.",
responses={
204: OpenApiResponse(description="Relationship created successfully"),
400: OpenApiResponse(
description="Bad request (e.g., relationship already exists)"
),
},
),
partial_update=extend_schema(
summary="Partially update a provider_group-providers relationship",
description="Update the provider_group-providers relationship information without affecting other fields.",
responses={
204: OpenApiResponse(
response=None, description="Relationship updated successfully"
)
},
),
destroy=extend_schema(
summary="Delete a provider_group-providers relationship",
description="Remove the provider_group-providers relationship from the system by their ID.",
responses={
204: OpenApiResponse(
response=None, description="Relationship deleted successfully"
)
},
),
)
class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupMembershipSerializer
resource_name = "providers"
http_method_names = ["post", "patch", "delete"]
schema = RelationshipViewSchema()
def get_queryset(self):
return ProviderGroup.objects.all()
def create(self, request, *args, **kwargs):
provider_group = self.get_object()
# Validate input data
serializer = self.get_serializer_class()(
data=request.data,
context=self.get_serializer_context(),
provider_ids = [item["id"] for item in request.data]
existing_relationships = ProviderGroupMembership.objects.filter(
provider_group=provider_group, provider_id__in=provider_ids
)
if existing_relationships.exists():
return Response(
{
"detail": "One or more providers are already associated with the provider_group."
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = self.get_serializer(
data={"providers": request.data},
context={
"provider_group": provider_group,
"tenant_id": self.request.tenant_id,
"request": request,
},
)
serializer.is_valid(raise_exception=True)
serializer.save()
provider_ids = serializer.validated_data["provider_ids"]
return Response(status=status.HTTP_204_NO_CONTENT)
# 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
def partial_update(self, request, *args, **kwargs):
provider_group = self.get_object()
serializer = self.get_serializer(
instance=provider_group,
data={"providers": request.data},
context={"tenant_id": self.request.tenant_id, "request": request},
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(status=status.HTTP_204_NO_CONTENT)
# 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)
def destroy(self, request, *args, **kwargs):
provider_group = self.get_object()
provider_group.providers.clear()
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema_view(