From 33857109c962c78e5653ae8df24b014b87233967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Jes=C3=BAs=20Pe=C3=B1a=20Rodr=C3=ADguez?= Date: Wed, 18 Dec 2024 16:45:32 +0100 Subject: [PATCH] ref(rbac): enable relationship creation when objects is created (#6238) --- api/src/backend/api/models.py | 4 +- api/src/backend/api/specs/v1.yaml | 494 +++++++++++++++++++----- api/src/backend/api/tests/test_views.py | 390 +++++++++++++++++-- api/src/backend/api/v1/serializers.py | 177 +++++++-- api/src/backend/api/v1/views.py | 5 +- api/src/backend/conftest.py | 14 + 6 files changed, 929 insertions(+), 155 deletions(-) diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index a3325f583e9..bba12919651 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -309,7 +309,7 @@ class Meta: ] class JSONAPIMeta: - resource_name = "provider-group" + resource_name = "provider-groups" class ProviderGroupMembership(RowLevelSecurityProtectedModel): @@ -926,7 +926,7 @@ class Meta: ] class JSONAPIMeta: - resource_name = "role" + resource_name = "roles" class RoleProviderGroupRelationship(RowLevelSecurityProtectedModel): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index eca184a1931..e274a0c9e11 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1696,7 +1696,7 @@ paths: summary: List all provider groups parameters: - in: query - name: fields[provider-group] + name: fields[provider-groups] schema: type: array items: @@ -1833,13 +1833,13 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ProviderGroupRequest' + $ref: '#/components/schemas/ProviderGroupCreateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/ProviderGroupRequest' + $ref: '#/components/schemas/ProviderGroupCreateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/ProviderGroupRequest' + $ref: '#/components/schemas/ProviderGroupCreateRequest' required: true security: - jwtAuth: [] @@ -1848,7 +1848,7 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ProviderGroupResponse' + $ref: '#/components/schemas/ProviderGroupCreateResponse' description: '' /api/v1/provider-groups/{id}: get: @@ -1858,7 +1858,7 @@ paths: summary: Retrieve data from a provider group parameters: - in: query - name: fields[provider-group] + name: fields[provider-groups] schema: type: array items: @@ -2939,7 +2939,7 @@ paths: summary: List all roles parameters: - in: query - name: fields[role] + name: fields[roles] schema: type: array items: @@ -3138,7 +3138,7 @@ paths: summary: Retrieve data from a role parameters: - in: query - name: fields[role] + name: fields[roles] schema: type: array items: @@ -3750,6 +3750,7 @@ paths: name: filter[state] schema: type: string + title: Task State enum: - available - cancelled @@ -3758,6 +3759,8 @@ paths: - failed - scheduled description: |- + Current state of the task being run + * `available` - Available * `scheduled` - Scheduled * `executing` - Executing @@ -5536,7 +5539,7 @@ components: type: type: string enum: - - role + - roles 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 @@ -5546,8 +5549,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles inviter: type: object properties: @@ -5689,7 +5692,7 @@ components: type: type: string enum: - - role + - roles 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 @@ -5699,8 +5702,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles required: - roles InvitationCreateRequest: @@ -5796,7 +5799,7 @@ components: type: type: string enum: - - role + - roles title: Resource Type Name description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share @@ -5806,8 +5809,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles required: - roles required: @@ -5887,7 +5890,7 @@ components: type: type: string enum: - - role + - roles 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 @@ -5897,8 +5900,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles required: - roles InvitationUpdateResponse: @@ -6422,7 +6425,7 @@ components: type: type: string enum: - - role + - roles title: Resource Type Name description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share @@ -6432,8 +6435,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles required: - roles required: @@ -6486,7 +6489,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - provider-group + - provider-groups id: type: string format: uuid @@ -6497,8 +6500,75 @@ components: 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: + - providers + 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 providers + title: providers + roles: + 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: + - roles + 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 roles + title: roles required: - data PatchedProviderSecretUpdateRequest: @@ -6765,7 +6835,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - role + - roles id: type: string format: uuid @@ -6788,10 +6858,109 @@ components: type: boolean manage_scans: type: boolean + permission_state: + type: string + readOnly: true unlimited_visibility: type: boolean + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true required: - name + relationships: + type: object + properties: + provider_groups: + 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-groups + 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-groups + title: provider-groups + users: + 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: + - users + 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 users + title: users + invitations: + 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: + - invitations + 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 invitations + title: invitations + readOnly: true required: - data PatchedScanUpdateRequest: @@ -6962,6 +7131,37 @@ components: required: - name - email + relationships: + type: object + properties: + roles: + 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: + - roles + 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 roles + title: roles required: - data Provider: @@ -7068,7 +7268,7 @@ components: type: type: string enum: - - provider-group + - provider-groups 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 @@ -7078,8 +7278,8 @@ components: - type required: - data - description: A related resource object from type provider-group - title: provider-group + description: A related resource object from type provider-groups + title: provider-groups readOnly: true required: - secret @@ -7183,7 +7383,7 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/ProviderGroupTypeEnum' + - $ref: '#/components/schemas/Type34dEnum' description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. @@ -7237,7 +7437,6 @@ components: - data description: A related resource object from type providers title: providers - readOnly: true roles: type: object properties: @@ -7254,7 +7453,7 @@ components: type: type: string enum: - - role + - roles 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 @@ -7264,38 +7463,96 @@ components: - type required: - data - description: A related resource object from type role - title: role - readOnly: true - ProviderGroupMembershipRequest: + description: A related resource object from type roles + title: roles + ProviderGroupCreate: type: object + required: + - type + additionalProperties: false properties: - data: + type: + allOf: + - $ref: '#/components/schemas/Type34dEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + attributes: type: object - required: - - type - additionalProperties: false properties: - type: + name: 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 - attributes: + 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: - providers: + data: type: array items: - $ref: '#/components/schemas/ProviderResourceIdentifierRequest' - description: List of resource identifier objects representing providers. + type: object + properties: + id: + type: string + format: uuid + title: Resource Identifier + description: The identifier of the related object. + type: + type: string + enum: + - providers + 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: - - providers - required: - - data - ProviderGroupRequest: + - data + description: A related resource object from type providers + title: providers + roles: + 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: + - roles + 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 roles + title: roles + ProviderGroupCreateRequest: type: object properties: data: @@ -7310,7 +7567,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - provider-group + - provider-groups attributes: type: object properties: @@ -7359,7 +7616,6 @@ components: - data description: A related resource object from type providers title: providers - readOnly: true roles: type: object properties: @@ -7376,7 +7632,7 @@ components: type: type: string enum: - - role + - roles title: Resource Type Name description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share @@ -7386,9 +7642,43 @@ components: - type required: - data - description: A related resource object from type role - title: role - readOnly: true + description: A related resource object from type roles + title: roles + required: + - data + ProviderGroupCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ProviderGroupCreate' + required: + - data + ProviderGroupMembershipRequest: + 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-provider + attributes: + type: object + properties: + providers: + type: array + items: + $ref: '#/components/schemas/ProviderResourceIdentifierRequest' + description: List of resource identifier objects representing providers. + required: + - providers required: - data ProviderGroupResourceIdentifierRequest: @@ -7428,10 +7718,6 @@ components: $ref: '#/components/schemas/ProviderGroup' required: - data - ProviderGroupTypeEnum: - type: string - enum: - - provider-group ProviderResourceIdentifierRequest: type: object properties: @@ -8234,7 +8520,7 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type1aaEnum' + - $ref: '#/components/schemas/Type6bbEnum' description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. @@ -8293,7 +8579,7 @@ components: type: type: string enum: - - provider-group + - provider-groups 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 @@ -8303,8 +8589,8 @@ components: - type required: - data - description: A related resource object from type provider-group - title: provider-group + description: A related resource object from type provider-groups + title: provider-groups users: type: object properties: @@ -8333,7 +8619,6 @@ components: - data description: A related resource object from type users title: users - readOnly: true invitations: type: object properties: @@ -8363,8 +8648,6 @@ components: description: A related resource object from type invitations title: invitations readOnly: true - required: - - provider_groups RoleCreate: type: object required: @@ -8373,7 +8656,7 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type1aaEnum' + - $ref: '#/components/schemas/Type6bbEnum' description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. @@ -8429,7 +8712,7 @@ components: type: type: string enum: - - provider-group + - provider-groups 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 @@ -8439,8 +8722,8 @@ components: - type required: - data - description: A related resource object from type provider-group - title: provider-group + description: A related resource object from type provider-groups + title: provider-groups users: type: object properties: @@ -8469,7 +8752,6 @@ components: - data description: A related resource object from type users title: users - readOnly: true invitations: type: object properties: @@ -8499,8 +8781,6 @@ components: description: A related resource object from type invitations title: invitations readOnly: true - required: - - provider_groups RoleCreateRequest: type: object properties: @@ -8516,7 +8796,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - role + - roles attributes: type: object properties: @@ -8570,7 +8850,7 @@ components: type: type: string enum: - - provider-group + - provider-groups title: Resource Type Name description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share @@ -8580,8 +8860,8 @@ components: - type required: - data - description: A related resource object from type provider-group - title: provider-group + description: A related resource object from type provider-groups + title: provider-groups users: type: object properties: @@ -8610,7 +8890,6 @@ components: - data description: A related resource object from type users title: users - readOnly: true invitations: type: object properties: @@ -8640,8 +8919,6 @@ components: description: A related resource object from type invitations title: invitations readOnly: true - required: - - provider_groups required: - data RoleCreateResponse: @@ -9319,14 +9596,18 @@ components: type: string enum: - provider-secrets - Type1aaEnum: - type: string - enum: - - role Type227Enum: type: string enum: - providers + Type34dEnum: + type: string + enum: + - provider-groups + Type6bbEnum: + type: string + enum: + - roles Type7f7Enum: type: string enum: @@ -9429,7 +9710,7 @@ components: type: type: string enum: - - role + - roles 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 @@ -9439,8 +9720,8 @@ components: - type required: - data - description: A related resource object from type role - title: role + description: A related resource object from type roles + title: roles readOnly: true UserCreate: type: object @@ -9596,6 +9877,37 @@ components: required: - name - email + relationships: + type: object + properties: + roles: + 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: + - roles + 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 roles + title: roles UserUpdateResponse: type: object properties: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 9b06381d8fa..c3d16b32fd3 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -815,7 +815,7 @@ def test_providers_list(self, authenticated_client, providers_fixture): @pytest.mark.parametrize( "include_values, expected_resources", [ - ("provider_groups", ["provider-group"]), + ("provider_groups", ["provider-groups"]), ], ) def test_providers_list_include( @@ -1200,7 +1200,7 @@ def test_provider_group_retrieve( def test_provider_group_create(self, authenticated_client): data = { "data": { - "type": "provider-group", + "type": "provider-groups", "attributes": { "name": "Test Provider Group", }, @@ -1219,7 +1219,7 @@ def test_provider_group_create(self, authenticated_client): def test_provider_group_create_invalid(self, authenticated_client): data = { "data": { - "type": "provider-group", + "type": "provider-groups", "attributes": { # Name is missing }, @@ -1241,7 +1241,7 @@ def test_provider_group_partial_update( data = { "data": { "id": str(provider_group.id), - "type": "provider-group", + "type": "provider-groups", "attributes": { "name": "Updated Provider Group Name", }, @@ -1263,7 +1263,7 @@ def test_provider_group_partial_update_invalid( data = { "data": { "id": str(provider_group.id), - "type": "provider-group", + "type": "provider-groups", "attributes": { "name": "", # Invalid name }, @@ -1327,6 +1327,170 @@ 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 + def test_provider_group_create_with_relationships( + self, authenticated_client, providers_fixture, roles_fixture + ): + provider1, provider2, *_ = providers_fixture + role1, role2, *_ = roles_fixture + + data = { + "data": { + "type": "provider-groups", + "attributes": {"name": "Test Provider Group with relationships"}, + "relationships": { + "providers": { + "data": [ + {"type": "providers", "id": str(provider1.id)}, + {"type": "providers", "id": str(provider2.id)}, + ] + }, + "roles": { + "data": [ + {"type": "roles", "id": str(role1.id)}, + {"type": "roles", "id": str(role2.id)}, + ] + }, + }, + } + } + + 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"] + group = ProviderGroup.objects.get(id=response_data["id"]) + assert group.name == "Test Provider Group with relationships" + assert set(group.providers.all()) == {provider1, provider2} + assert set(group.roles.all()) == {role1, role2} + + def test_provider_group_update_relationships( + self, + authenticated_client, + provider_groups_fixture, + providers_fixture, + roles_fixture, + ): + group = provider_groups_fixture[0] + provider3 = providers_fixture[2] + provider4 = providers_fixture[3] + role3 = roles_fixture[2] + role4 = roles_fixture[3] + + data = { + "data": { + "id": str(group.id), + "type": "provider-groups", + "relationships": { + "providers": { + "data": [ + {"type": "providers", "id": str(provider3.id)}, + {"type": "providers", "id": str(provider4.id)}, + ] + }, + "roles": { + "data": [ + {"type": "roles", "id": str(role3.id)}, + {"type": "roles", "id": str(role4.id)}, + ] + }, + }, + } + } + + response = authenticated_client.patch( + reverse("providergroup-detail", kwargs={"pk": group.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK + group.refresh_from_db() + assert set(group.providers.all()) == {provider3, provider4} + assert set(group.roles.all()) == {role3, role4} + + def test_provider_group_clear_relationships( + self, authenticated_client, providers_fixture, provider_groups_fixture + ): + group = provider_groups_fixture[0] + provider3 = providers_fixture[2] + provider4 = providers_fixture[3] + + data = { + "data": { + "id": str(group.id), + "type": "provider-groups", + "relationships": { + "providers": { + "data": [ + {"type": "providers", "id": str(provider3.id)}, + {"type": "providers", "id": str(provider4.id)}, + ] + } + }, + } + } + + response = authenticated_client.patch( + reverse("providergroup-detail", kwargs={"pk": group.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK + + data = { + "data": { + "id": str(group.id), + "type": "provider-groups", + "relationships": { + "providers": { + "data": [] # Removing all providers + }, + "roles": { + "data": [] # Removing all roles + }, + }, + } + } + + response = authenticated_client.patch( + reverse("providergroup-detail", kwargs={"pk": group.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK + group.refresh_from_db() + assert group.providers.count() == 0 + assert group.roles.count() == 0 + + def test_provider_group_create_with_invalid_relationships( + self, authenticated_client + ): + invalid_provider_id = "non-existent-id" + data = { + "data": { + "type": "provider-groups", + "attributes": {"name": "Invalid relationships test"}, + "relationships": { + "providers": { + "data": [{"type": "providers", "id": invalid_provider_id}] + } + }, + } + } + + response = authenticated_client.post( + reverse("providergroup-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code in [status.HTTP_400_BAD_REQUEST] + @pytest.mark.django_db class TestProviderSecretViewSet: @@ -2571,7 +2735,7 @@ def test_invitations_create_valid( }, "relationships": { "roles": { - "data": [{"type": "role", "id": str(roles_fixture[0].id)}] + "data": [{"type": "roles", "id": str(roles_fixture[0].id)}] } }, } @@ -2670,9 +2834,10 @@ def test_invitations_create_invalid_expires_at( ) def test_invitations_partial_update_valid( - self, authenticated_client, invitations_fixture + self, authenticated_client, invitations_fixture, roles_fixture ): invitation, *_ = invitations_fixture + role1, role2, *_ = roles_fixture new_email = "new_email@prowler.com" new_expires_at = datetime.now(timezone.utc) + timedelta(days=7) new_expires_at_iso = new_expires_at.isoformat() @@ -2684,6 +2849,14 @@ def test_invitations_partial_update_valid( "email": new_email, "expires_at": new_expires_at_iso, }, + "relationships": { + "roles": { + "data": [ + {"type": "roles", "id": str(role1.id)}, + {"type": "roles", "id": str(role2.id)}, + ] + }, + }, } } assert invitation.email != new_email @@ -2702,6 +2875,7 @@ def test_invitations_partial_update_valid( assert invitation.email == new_email assert invitation.expires_at == new_expires_at + assert invitation.roles.count() == 2 @pytest.mark.parametrize( "email", @@ -3121,7 +3295,7 @@ def test_role_retrieve_permission_state( def test_role_create(self, authenticated_client): data = { "data": { - "type": "role", + "type": "roles", "attributes": { "name": "Test Role", "manage_users": "false", @@ -3150,7 +3324,7 @@ def test_role_provider_groups_create( ): data = { "data": { - "type": "role", + "type": "roles", "attributes": { "name": "Test Role", "manage_users": "false", @@ -3164,7 +3338,7 @@ def test_role_provider_groups_create( "relationships": { "provider_groups": { "data": [ - {"type": "provider-group", "id": str(provider_group.id)} + {"type": "provider-groups", "id": str(provider_group.id)} for provider_group in provider_groups_fixture[:2] ] } @@ -3190,7 +3364,7 @@ def test_role_provider_groups_create( def test_role_create_invalid(self, authenticated_client): data = { "data": { - "type": "role", + "type": "roles", "attributes": { # Name is missing }, @@ -3210,7 +3384,7 @@ def test_role_partial_update(self, authenticated_client, roles_fixture): data = { "data": { "id": str(role.id), - "type": "role", + "type": "roles", "attributes": { "name": "Updated Provider Group Name", }, @@ -3230,7 +3404,7 @@ def test_role_partial_update_invalid(self, authenticated_client, roles_fixture): data = { "data": { "id": str(role.id), - "type": "role", + "type": "roles", "attributes": { "name": "", # Invalid name }, @@ -3290,6 +3464,162 @@ def test_role_invalid_method(self, authenticated_client): response = authenticated_client.put(reverse("role-list")) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + def test_role_create_with_users_and_provider_groups( + self, authenticated_client, users_fixture, provider_groups_fixture + ): + user1, user2, *_ = users_fixture + pg1, pg2, *_ = provider_groups_fixture + + data = { + "data": { + "type": "roles", + "attributes": { + "name": "Role with Users and PGs", + "manage_users": "true", + "manage_account": "false", + "manage_billing": "true", + "manage_providers": "true", + "manage_integrations": "false", + "manage_scans": "false", + "unlimited_visibility": "false", + }, + "relationships": { + "users": { + "data": [ + {"type": "users", "id": str(user1.id)}, + {"type": "users", "id": str(user2.id)}, + ] + }, + "provider_groups": { + "data": [ + {"type": "provider-groups", "id": str(pg1.id)}, + {"type": "provider-groups", "id": str(pg2.id)}, + ] + }, + }, + } + } + + response = authenticated_client.post( + reverse("role-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + created_role = Role.objects.get(name="Role with Users and PGs") + + assert created_role.users.count() == 2 + assert set(created_role.users.all()) == {user1, user2} + + assert created_role.provider_groups.count() == 2 + assert set(created_role.provider_groups.all()) == {pg1, pg2} + + def test_role_update_relationships( + self, + authenticated_client, + roles_fixture, + users_fixture, + provider_groups_fixture, + ): + role = roles_fixture[0] + user3 = users_fixture[2] + pg3 = provider_groups_fixture[2] + + data = { + "data": { + "id": str(role.id), + "type": "roles", + "relationships": { + "users": { + "data": [ + {"type": "users", "id": str(user3.id)}, + ] + }, + "provider_groups": { + "data": [ + {"type": "provider-groups", "id": str(pg3.id)}, + ] + }, + }, + } + } + + response = authenticated_client.patch( + reverse("role-detail", kwargs={"pk": role.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + role.refresh_from_db() + + assert role.users.count() == 1 + assert role.users.first() == user3 + assert role.provider_groups.count() == 1 + assert role.provider_groups.first() == pg3 + + def test_role_clear_relationships(self, authenticated_client, roles_fixture): + role = roles_fixture[0] + data = { + "data": { + "id": str(role.id), + "type": "roles", + "relationships": { + "users": { + "data": [] # Clearing all users + }, + "provider_groups": { + "data": [] # Clearing all provider groups + }, + }, + } + } + + response = authenticated_client.patch( + reverse("role-detail", kwargs={"pk": role.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + role.refresh_from_db() + assert role.users.count() == 0 + assert role.provider_groups.count() == 0 + + def test_role_create_with_invalid_user_relationship( + self, authenticated_client, provider_groups_fixture + ): + invalid_user_id = "non-existent-user-id" + pg = provider_groups_fixture[0] + + data = { + "data": { + "type": "roles", + "attributes": { + "name": "Invalid Users Role", + "manage_users": "false", + "manage_account": "false", + "manage_billing": "false", + "manage_providers": "true", + "manage_integrations": "true", + "manage_scans": "true", + "unlimited_visibility": "true", + }, + "relationships": { + "users": {"data": [{"type": "users", "id": invalid_user_id}]}, + "provider_groups": { + "data": [{"type": "provider-groups", "id": str(pg.id)}] + }, + }, + } + } + + response = authenticated_client.post( + reverse("role-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code in [status.HTTP_400_BAD_REQUEST] + @pytest.mark.django_db class TestUserRoleRelationshipViewSet: @@ -3297,7 +3627,9 @@ def test_create_relationship( self, authenticated_client, roles_fixture, create_test_user ): data = { - "data": [{"type": "role", "id": str(role.id)} for role in roles_fixture[:2]] + "data": [ + {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2] + ] } response = authenticated_client.post( reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}), @@ -3314,7 +3646,9 @@ def test_create_relationship_already_exists( self, authenticated_client, roles_fixture, create_test_user ): data = { - "data": [{"type": "role", "id": str(role.id)} for role in roles_fixture[:2]] + "data": [ + {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2] + ] } authenticated_client.post( reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}), @@ -3324,7 +3658,7 @@ def test_create_relationship_already_exists( data = { "data": [ - {"type": "role", "id": str(roles_fixture[0].id)}, + {"type": "roles", "id": str(roles_fixture[0].id)}, ] } response = authenticated_client.post( @@ -3341,7 +3675,7 @@ def test_partial_update_relationship( ): data = { "data": [ - {"type": "role", "id": str(roles_fixture[2].id)}, + {"type": "roles", "id": str(roles_fixture[2].id)}, ] } response = authenticated_client.patch( @@ -3356,8 +3690,8 @@ def test_partial_update_relationship( data = { "data": [ - {"type": "role", "id": str(roles_fixture[1].id)}, - {"type": "role", "id": str(roles_fixture[2].id)}, + {"type": "roles", "id": str(roles_fixture[1].id)}, + {"type": "roles", "id": str(roles_fixture[2].id)}, ] } response = authenticated_client.patch( @@ -3385,7 +3719,7 @@ def test_destroy_relationship( def test_invalid_provider_group_id(self, authenticated_client, create_test_user): invalid_id = "non-existent-id" - data = {"data": [{"type": "provider-group", "id": invalid_id}]} + data = {"data": [{"type": "provider-groups", "id": invalid_id}]} response = authenticated_client.post( reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}), data=data, @@ -3403,7 +3737,7 @@ def test_create_relationship( ): data = { "data": [ - {"type": "provider-group", "id": str(provider_group.id)} + {"type": "provider-groups", "id": str(provider_group.id)} for provider_group in provider_groups_fixture[:2] ] } @@ -3429,7 +3763,7 @@ def test_create_relationship_already_exists( ): data = { "data": [ - {"type": "provider-group", "id": str(provider_group.id)} + {"type": "provider-groups", "id": str(provider_group.id)} for provider_group in provider_groups_fixture[:2] ] } @@ -3443,7 +3777,7 @@ def test_create_relationship_already_exists( data = { "data": [ - {"type": "provider-group", "id": str(provider_groups_fixture[0].id)}, + {"type": "provider-groups", "id": str(provider_groups_fixture[0].id)}, ] } response = authenticated_client.post( @@ -3462,7 +3796,7 @@ def test_partial_update_relationship( ): data = { "data": [ - {"type": "provider-group", "id": str(provider_groups_fixture[1].id)}, + {"type": "provider-groups", "id": str(provider_groups_fixture[1].id)}, ] } response = authenticated_client.patch( @@ -3483,8 +3817,8 @@ def test_partial_update_relationship( data = { "data": [ - {"type": "provider-group", "id": str(provider_groups_fixture[1].id)}, - {"type": "provider-group", "id": str(provider_groups_fixture[2].id)}, + {"type": "provider-groups", "id": str(provider_groups_fixture[1].id)}, + {"type": "provider-groups", "id": str(provider_groups_fixture[2].id)}, ] } response = authenticated_client.patch( @@ -3520,7 +3854,7 @@ def test_destroy_relationship( def test_invalid_provider_group_id(self, authenticated_client, roles_fixture): invalid_id = "non-existent-id" - data = {"data": [{"type": "provider-group", "id": invalid_id}]} + data = {"data": [{"type": "provider-groups", "id": invalid_id}]} response = authenticated_client.post( reverse( "role-provider-groups-relationship", kwargs={"pk": roles_fixture[1].id} @@ -3681,7 +4015,7 @@ def test_invalid_provider_group_id( ): provider_group, *_ = provider_groups_fixture invalid_id = "non-existent-id" - data = {"data": [{"type": "provider-group", "id": invalid_id}]} + data = {"data": [{"type": "provider-groups", "id": invalid_id}]} response = authenticated_client.post( reverse( "provider_group-providers-relationship", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index d18bfa1cea8..a2aec3d64c9 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -445,7 +445,12 @@ class Meta: # Provider Groups class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer): - providers = serializers.ResourceRelatedField(many=True, read_only=True) + providers = serializers.ResourceRelatedField( + queryset=Provider.objects.all(), many=True, required=False + ) + roles = serializers.ResourceRelatedField( + queryset=Role.objects.all(), many=True, required=False + ) def validate(self, attrs): if ProviderGroup.objects.filter(name=attrs.get("name")).exists(): @@ -475,21 +480,93 @@ class Meta: } -class ProviderGroupIncludedSerializer(RLSSerializer, BaseWriteSerializer): +class ProviderGroupIncludedSerializer(ProviderGroupSerializer): class Meta: model = ProviderGroup fields = ["id", "name"] -class ProviderGroupUpdateSerializer(RLSSerializer, BaseWriteSerializer): - """ - Serializer for updating the ProviderGroup model. - Only allows "name" field to be updated. - """ +class ProviderGroupCreateSerializer(ProviderGroupSerializer): + providers = serializers.ResourceRelatedField( + queryset=Provider.objects.all(), many=True, required=False + ) + roles = serializers.ResourceRelatedField( + queryset=Role.objects.all(), many=True, required=False + ) class Meta: model = ProviderGroup - fields = ["id", "name"] + fields = [ + "id", + "name", + "inserted_at", + "updated_at", + "providers", + "roles", + ] + + def create(self, validated_data): + providers = validated_data.pop("providers", []) + roles = validated_data.pop("roles", []) + tenant_id = self.context.get("tenant_id") + provider_group = ProviderGroup.objects.create( + tenant_id=tenant_id, **validated_data + ) + + through_model_instances = [ + ProviderGroupMembership( + provider_group=provider_group, + provider=provider, + tenant_id=tenant_id, + ) + for provider in providers + ] + ProviderGroupMembership.objects.bulk_create(through_model_instances) + + through_model_instances = [ + RoleProviderGroupRelationship( + provider_group=provider_group, + role=role, + tenant_id=tenant_id, + ) + for role in roles + ] + RoleProviderGroupRelationship.objects.bulk_create(through_model_instances) + + return provider_group + + +class ProviderGroupUpdateSerializer(ProviderGroupSerializer): + def update(self, instance, validated_data): + tenant_id = self.context.get("tenant_id") + + if "providers" in validated_data: + providers = validated_data.pop("providers") + instance.providers.clear() + through_model_instances = [ + ProviderGroupMembership( + provider_group=instance, + provider=provider, + tenant_id=tenant_id, + ) + for provider in providers + ] + ProviderGroupMembership.objects.bulk_create(through_model_instances) + + if "roles" in validated_data: + roles = validated_data.pop("roles") + instance.roles.clear() + through_model_instances = [ + RoleProviderGroupRelationship( + provider_group=instance, + role=role, + tenant_id=tenant_id, + ) + for role in roles + ] + RoleProviderGroupRelationship.objects.bulk_create(through_model_instances) + + return super().update(instance, validated_data) class ProviderResourceIdentifierSerializer(serializers.Serializer): @@ -1235,6 +1312,10 @@ def create(self, validated_data): class InvitationUpdateSerializer(InvitationBaseWriteSerializer): + roles = serializers.ResourceRelatedField( + required=False, many=True, queryset=Role.objects.all() + ) + class Meta: model = Invitation fields = ["id", "email", "expires_at", "state", "token", "roles"] @@ -1247,15 +1328,19 @@ class Meta: } def update(self, instance, validated_data): - roles = validated_data.pop("roles", []) tenant_id = self.context.get("tenant_id") - invitation = super().update(instance, validated_data) - if roles: + if "roles" in validated_data: + roles = validated_data.pop("roles") instance.roles.clear() - for role in roles: - InvitationRoleRelationship.objects.create( - role=role, invitation=invitation, tenant_id=tenant_id + new_relationships = [ + InvitationRoleRelationship( + role=r, invitation=instance, tenant_id=tenant_id ) + for r in roles + ] + InvitationRoleRelationship.objects.bulk_create(new_relationships) + + invitation = super().update(instance, validated_data) return invitation @@ -1274,12 +1359,15 @@ class Meta: class RoleSerializer(RLSSerializer, BaseWriteSerializer): + permission_state = serializers.SerializerMethodField() + users = serializers.ResourceRelatedField( + queryset=User.objects.all(), many=True, required=False + ) provider_groups = serializers.ResourceRelatedField( - many=True, queryset=ProviderGroup.objects.all() + queryset=ProviderGroup.objects.all(), many=True, required=False ) - permission_state = serializers.SerializerMethodField() - def get_permission_state(self, obj): + def get_permission_state(self, obj) -> str: return obj.permission_state def validate(self, attrs): @@ -1323,12 +1411,18 @@ class Meta: "id": {"read_only": True}, "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, - "users": {"read_only": True}, "url": {"read_only": True}, } class RoleCreateSerializer(RoleSerializer): + provider_groups = serializers.ResourceRelatedField( + many=True, queryset=ProviderGroup.objects.all(), required=False + ) + users = serializers.ResourceRelatedField( + many=True, queryset=User.objects.all(), required=False + ) + def create(self, validated_data): provider_groups = validated_data.pop("provider_groups", []) users = validated_data.pop("users", []) @@ -1347,7 +1441,7 @@ def create(self, validated_data): through_model_instances = [ UserRoleRelationship( - role=user, + role=role, user=user, tenant_id=tenant_id, ) @@ -1358,20 +1452,37 @@ def create(self, validated_data): return role -class RoleUpdateSerializer(RLSSerializer, BaseWriteSerializer): - class Meta: - model = Role - fields = [ - "id", - "name", - "manage_users", - "manage_account", - "manage_billing", - "manage_providers", - "manage_integrations", - "manage_scans", - "unlimited_visibility", - ] +class RoleUpdateSerializer(RoleSerializer): + def update(self, instance, validated_data): + tenant_id = self.context.get("tenant_id") + + if "provider_groups" in validated_data: + provider_groups = validated_data.pop("provider_groups") + instance.provider_groups.clear() + through_model_instances = [ + RoleProviderGroupRelationship( + role=instance, + provider_group=provider_group, + tenant_id=tenant_id, + ) + for provider_group in provider_groups + ] + RoleProviderGroupRelationship.objects.bulk_create(through_model_instances) + + if "users" in validated_data: + users = validated_data.pop("users") + instance.users.clear() + through_model_instances = [ + UserRoleRelationship( + role=instance, + user=user, + tenant_id=tenant_id, + ) + for user in users + ] + UserRoleRelationship.objects.bulk_create(through_model_instances) + + return super().update(instance, validated_data) class ProviderGroupResourceIdentifierSerializer(serializers.Serializer): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 948439b88e7..cc4e4f507d5 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -100,6 +100,7 @@ ProviderCreateSerializer, ProviderGroupMembershipSerializer, ProviderGroupSerializer, + ProviderGroupCreateSerializer, ProviderGroupUpdateSerializer, ProviderSecretCreateSerializer, ProviderSecretSerializer, @@ -741,7 +742,9 @@ def get_queryset(self): return user_roles.provider_groups.all() def get_serializer_class(self): - if self.action == "partial_update": + if self.action == "create": + return ProviderGroupCreateSerializer + elif self.action == "partial_update": return ProviderGroupUpdateSerializer return super().get_serializer_class() diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 1747c3ae705..d05de318045 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -322,6 +322,20 @@ def invitations_fixture(create_test_user, tenants_fixture): return valid_invitation, expired_invitation +@pytest.fixture +def users_fixture(django_user_model): + user1 = User.objects.create_user( + name="user1", email="test_unit0@prowler.com", password="S3cret" + ) + user2 = User.objects.create_user( + name="user2", email="test_unit1@prowler.com", password="S3cret" + ) + user3 = User.objects.create_user( + name="user3", email="test_unit2@prowler.com", password="S3cret" + ) + return user1, user2, user3 + + @pytest.fixture def providers_fixture(tenants_fixture): tenant, *_ = tenants_fixture