From 67d9ff2419f17a86bb2575a3a7306decc11f18c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Jes=C3=BAs=20Pe=C3=B1a=20Rodr=C3=ADguez?= Date: Tue, 10 Dec 2024 09:28:22 +0100 Subject: [PATCH] ref(provider_groups): ref the provider_groups relationships endpoint (#6033) --- .../backend/api/fixtures/dev/6_dev_rbac.json | 12 +- .../backend/api/migrations/0001_initial.py | 233 ---------------- api/src/backend/api/migrations/0003_rbac.py | 246 +++++++++++++++++ api/src/backend/api/models.py | 20 +- api/src/backend/api/specs/v1.yaml | 158 ++++++++--- api/src/backend/api/tests/test_views.py | 256 +++++++++++------- api/src/backend/api/v1/serializers.py | 82 ++++-- api/src/backend/api/v1/urls.py | 10 +- api/src/backend/api/v1/views.py | 124 ++++++--- 9 files changed, 687 insertions(+), 454 deletions(-) create mode 100644 api/src/backend/api/migrations/0003_rbac.py diff --git a/api/src/backend/api/fixtures/dev/6_dev_rbac.json b/api/src/backend/api/fixtures/dev/6_dev_rbac.json index 3c85f89a0b..a33d9e1d39 100644 --- a/api/src/backend/api/fixtures/dev/6_dev_rbac.json +++ b/api/src/backend/api/fixtures/dev/6_dev_rbac.json @@ -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" } } ] diff --git a/api/src/backend/api/migrations/0001_initial.py b/api/src/backend/api/migrations/0001_initial.py index 6b39fe3fe2..537880ed72 100644 --- a/api/src/backend/api/migrations/0001_initial.py +++ b/api/src/backend/api/migrations/0001_initial.py @@ -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=[ diff --git a/api/src/backend/api/migrations/0003_rbac.py b/api/src/backend/api/migrations/0003_rbac.py new file mode 100644 index 0000000000..dd0936c5be --- /dev/null +++ b/api/src/backend/api/migrations/0003_rbac.py @@ -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", + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 8f4f2584d1..4b57f821b8 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -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" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 6895641dfc..618145b031 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -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: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 435997bafa..d735ed76d9 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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): diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 6aa419857d..f80959bf88 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -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 diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index b23a3efa80..fd3f9ad18a 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -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//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)), diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 70208d896d..7189dbcf34 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -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(