From 54bb034cac34337d745e45a28507ee879e301ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Mon, 30 Sep 2024 17:45:09 +0200 Subject: [PATCH] feat(Tokens): PRWLR-4718 implement user authorization with JWT (#47) * feat(Users): PRWLR-4718 make user email the default login username * feat(Token): PRWLR-4718 add serializers, views and urls for access and refresh tokens * feat(Token): PRWLR-4718 add first membership tenant in token if not present in json body * feat(Users): PRWLR-4718 add company_name to model * feat(Users): PRWLR-4718 create tenant and membership when creating new user * fix(BaseView): PRWLR-4718 add tenant_id to serializer context * fix(Tests): PRWLR-4718 use authorization with unit tests * fix(Views): PRWLR-4718 fix tenant retrieval from request * fix(Tests): PRWLR-4718 fix tests * fix(Fixtures): PRWLR-4718 fix tenant memberships ordering * chore(Tokens): PRWLR-4718 update token url * chore(Spec): PRWLR-4718 update API spec * feat(Tokens): PRWLR-4718 enable token refresh blacklisting * feat(Tokens): PRWLR-4718 implement RS256 algorithm and dev valid keys * fix(Environment): PRWLR-4718 fix jwt keys env vars * fix(Environment): PRWLR-4718 fix jwt keys env vars (testing) * chore(Settings): PRWLR-4718 remove drf-spectacular unused settings * fix(Environment): PRWLR-4718 remove jwt signature keys from dev and testing modules --- src/backend/api/base_views.py | 20 +- src/backend/api/db_utils.py | 4 +- src/backend/api/exceptions.py | 5 + src/backend/api/fixtures/0_dev_users.json | 6 +- src/backend/api/fixtures/1_dev_tenants.json | 20 +- src/backend/api/migrations/0001_initial.py | 21 +- .../api/migrations/0002_token_migrations.py | 22 + src/backend/api/models.py | 14 +- src/backend/api/specs/v1.yaml | 618 ++++++++++++++---- .../tests/integration/test_authentication.py | 75 ++- .../api/tests/integration/test_tenants.py | 24 +- src/backend/api/tests/test_middleware.py | 9 - src/backend/api/tests/test_views.py | 356 ++++------ src/backend/api/v1/serializers.py | 106 ++- src/backend/api/v1/urls.py | 4 + src/backend/api/v1/views.py | 74 ++- src/backend/config/django/base.py | 14 +- src/backend/config/django/testing.py | 5 + src/backend/conftest.py | 57 +- 19 files changed, 987 insertions(+), 467 deletions(-) create mode 100644 src/backend/api/migrations/0002_token_migrations.py diff --git a/src/backend/api/base_views.py b/src/backend/api/base_views.py index 4bb1c0d25b..36db423cc1 100644 --- a/src/backend/api/base_views.py +++ b/src/backend/api/base_views.py @@ -2,18 +2,18 @@ import uuid from django.db import transaction, connection from rest_framework import permissions -from rest_framework.authentication import BasicAuthentication from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter from rest_framework_json_api import filters from rest_framework_json_api.serializers import ValidationError from rest_framework_json_api.views import ModelViewSet +from rest_framework_simplejwt.authentication import JWTAuthentication from api.filters import CustomDjangoFilterBackend class BaseViewSet(ModelViewSet): - authentication_classes = [BasicAuthentication] + authentication_classes = [JWTAuthentication] permission_classes = [permissions.IsAuthenticated] filter_backends = [ filters.QueryParameterValidationFilter, @@ -40,26 +40,26 @@ class BaseRLSViewSet(BaseViewSet): def initial(self, request, *args, **kwargs): # Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it # https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.setup - if "X-Tenant-ID" not in request.headers: - # This will return a 403 until we implement authentication/authorization - # https://www.django-rest-framework.org/api-guide/authentication/#unauthorized-and-forbidden-responses - raise NotAuthenticated("X-Tenant-ID header is required") + if request.auth is None: + raise NotAuthenticated - tenant_id = request.headers["X-Tenant-ID"] + tenant_id = request.auth.get("tenant_id") + if tenant_id is None: + raise NotAuthenticated("Tenant ID is not present in token") try: uuid.UUID(tenant_id) except ValueError: - raise ValidationError("X-Tenant-ID header must be a valid UUID") + raise ValidationError("Tenant ID must be a valid UUID") with connection.cursor() as cursor: cursor.execute(f"SELECT set_config('api.tenant_id', '{tenant_id}', TRUE);") + self.request.tenant_id = tenant_id return super().initial(request, *args, **kwargs) def get_serializer_context(self): context = super().get_serializer_context() - tenant_id = self.request.headers.get("X-Tenant-ID") - context["tenant_id"] = tenant_id + context["tenant_id"] = self.request.tenant_id return context diff --git a/src/backend/api/db_utils.py b/src/backend/api/db_utils.py index 636601a21e..774ae0953b 100644 --- a/src/backend/api/db_utils.py +++ b/src/backend/api/db_utils.py @@ -41,11 +41,11 @@ def psycopg_connection(database_alias: str): class CustomUserManager(BaseUserManager): - def create_user(self, username, email, password=None, **extra_fields): + def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("The email field must be set") email = self.normalize_email(email) - user = self.model(username=username.strip(), email=email, **extra_fields) + user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user diff --git a/src/backend/api/exceptions.py b/src/backend/api/exceptions.py index 7fdc5025e6..604ca0405c 100644 --- a/src/backend/api/exceptions.py +++ b/src/backend/api/exceptions.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError as django_validation_error from rest_framework_json_api.exceptions import exception_handler from rest_framework_json_api.serializers import ValidationError +from rest_framework_simplejwt.exceptions import TokenError, InvalidToken class ModelValidationError(ValidationError): @@ -29,4 +30,8 @@ def custom_exception_handler(exc, context): exc = ValidationError(exc.message_dict) else: exc = ValidationError(detail=exc.messages[0], code=exc.code) + elif isinstance(exc, (TokenError, InvalidToken)): + exc.detail["messages"] = [ + message_item["message"] for message_item in exc.detail["messages"] + ] return exception_handler(exc, context) diff --git a/src/backend/api/fixtures/0_dev_users.json b/src/backend/api/fixtures/0_dev_users.json index b902ff4623..87ce06239f 100644 --- a/src/backend/api/fixtures/0_dev_users.json +++ b/src/backend/api/fixtures/0_dev_users.json @@ -5,8 +5,9 @@ "fields": { "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=", "last_login": null, - "username": "prowler_dev", + "name": "Devie Prowlerson", "email": "dev@prowler.com", + "company_name": "Prowler Developers", "is_active": true, "date_joined": "2024-09-17T09:04:20.850Z" } @@ -17,8 +18,9 @@ "fields": { "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=", "last_login": null, - "username": "prowler_dev_2", + "name": "Devietoo Prowlerson", "email": "dev2@prowler.com", + "company_name": "Prowler Developers", "is_active": true, "date_joined": "2024-09-18T09:04:20.850Z" } diff --git a/src/backend/api/fixtures/1_dev_tenants.json b/src/backend/api/fixtures/1_dev_tenants.json index 6003a4b8ef..dd89b100d4 100644 --- a/src/backend/api/fixtures/1_dev_tenants.json +++ b/src/backend/api/fixtures/1_dev_tenants.json @@ -1,13 +1,4 @@ [ - { - "model": "api.tenant", - "pk": "0412980b-06e3-436a-ab98-3c9b1d0333d3", - "fields": { - "inserted_at": "2024-03-21T23:00:00Z", - "updated_at": "2024-03-21T23:00:00Z", - "name": "Tenant2" - } - }, { "model": "api.tenant", "pk": "12646005-9067-4d2a-a098-8bb378604362", @@ -17,6 +8,15 @@ "name": "Tenant1" } }, + { + "model": "api.tenant", + "pk": "0412980b-06e3-436a-ab98-3c9b1d0333d3", + "fields": { + "inserted_at": "2024-03-21T23:00:00Z", + "updated_at": "2024-03-21T23:00:00Z", + "name": "Tenant2" + } + }, { "model": "api.membership", "pk": "2b0db93a-7e0b-4edf-a851-ea448676b7eb", @@ -34,7 +34,7 @@ "user": "8b38e2eb-6689-4f1e-a4ba-95b275130200", "tenant": "12646005-9067-4d2a-a098-8bb378604362", "role": "owner", - "date_joined": "2024-09-19T11:03:59.712Z" + "date_joined": "2024-09-19T11:02:59.712Z" } }, { diff --git a/src/backend/api/migrations/0001_initial.py b/src/backend/api/migrations/0001_initial.py index d68ce8052a..db46623e09 100644 --- a/src/backend/api/migrations/0001_initial.py +++ b/src/backend/api/migrations/0001_initial.py @@ -2,7 +2,6 @@ import uuid from functools import partial import django.contrib.auth.models -import django.contrib.auth.validators import django.contrib.postgres.indexes import django.contrib.postgres.search import django.core.validators @@ -147,17 +146,8 @@ class Migration(migrations.Migration): blank=True, null=True, verbose_name="last login" ), ), - ( - "username", - models.CharField( - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - ), - ), ("email", models.EmailField(max_length=254, unique=True)), + ("company_name", models.CharField(max_length=150, blank=True)), ("is_active", models.BooleanField(default=True)), ("date_joined", models.DateTimeField(auto_now_add=True)), ], @@ -225,11 +215,14 @@ class Migration(migrations.Migration): ( "role", MemberRoleEnumField( - choices=[("scheduled", "Scheduled"), ("member", "Member")], + choices=[("owner", "Owner"), ("member", "Member")], default="member", ), ), - ("date_joined", models.DateTimeField(auto_now_add=True)), + ( + "date_joined", + models.DateTimeField(auto_now_add=True, editable=False), + ), ( "tenant", models.ForeignKey( @@ -891,8 +884,8 @@ class Migration(migrations.Migration): "status", api.db_utils.StatusEnumField( choices=[ - ("PASS", "Pass"), ("FAIL", "Fail"), + ("PASS", "Pass"), ("MANUAL", "Manual"), ("MUTED", "Muted"), ] diff --git a/src/backend/api/migrations/0002_token_migrations.py b/src/backend/api/migrations/0002_token_migrations.py new file mode 100644 index 0000000000..cc1ee6aed3 --- /dev/null +++ b/src/backend/api/migrations/0002_token_migrations.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.db import migrations + +from api.db_utils import DB_PROWLER_USER + +DB_NAME = settings.DATABASES["default"]["NAME"] + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0001_initial"), + ("token_blacklist", "0012_alter_outstandingtoken_user"), + ] + + operations = [ + migrations.RunSQL( + f""" + GRANT SELECT, INSERT, DELETE ON token_blacklist_blacklistedtoken TO {DB_PROWLER_USER}; + GRANT SELECT, INSERT, DELETE ON token_blacklist_outstandingtoken TO {DB_PROWLER_USER}; + """ + ), + ] diff --git a/src/backend/api/models.py b/src/backend/api/models.py index 5c8244cb63..92da9bb533 100644 --- a/src/backend/api/models.py +++ b/src/backend/api/models.py @@ -2,7 +2,6 @@ import re from uuid import uuid4, UUID from django.contrib.auth.models import AbstractBaseUser -from django.contrib.auth.validators import UnicodeUsernameValidator from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector, SearchVectorField from django.core.validators import MinLengthValidator @@ -64,18 +63,19 @@ class StateChoices(models.TextChoices): class User(AbstractBaseUser): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) name = models.CharField(max_length=150, validators=[MinLengthValidator(3)]) - username = models.CharField( - max_length=150, unique=True, validators=[UnicodeUsernameValidator()] - ) email = models.EmailField(max_length=254, unique=True) + company_name = models.CharField(max_length=150, blank=True) is_active = models.BooleanField(default=True) date_joined = models.DateTimeField(auto_now_add=True, editable=False) - USERNAME_FIELD = "username" - REQUIRED_FIELDS = ["name", "email"] + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["name"] objects = CustomUserManager() + def is_member_of_tenant(self, tenant_id): + return self.memberships.filter(tenant_id=tenant_id).exists() + class Meta: db_table = "users" @@ -106,7 +106,7 @@ class Membership(models.Model): related_query_name="membership", ) role = MemberRoleEnumField(choices=RoleChoices.choices, default=RoleChoices.MEMBER) - date_joined = models.DateTimeField(auto_now_add=True) + date_joined = models.DateTimeField(auto_now_add=True, editable=False) class Meta: db_table = "memberships" diff --git a/src/backend/api/specs/v1.yaml b/src/backend/api/specs/v1.yaml index c7467f5ebc..41947df798 100644 --- a/src/backend/api/specs/v1.yaml +++ b/src/backend/api/specs/v1.yaml @@ -16,7 +16,7 @@ paths: summary: List all findings parameters: - in: query - name: fields[findings] + name: fields[Findings] schema: type: array items: @@ -289,9 +289,9 @@ paths: - -updated_at explode: false tags: - - Findings + - Finding security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -306,7 +306,7 @@ paths: summary: Retrieve data from a specific finding parameters: - in: query - name: fields[findings] + name: fields[Findings] schema: type: array items: @@ -347,9 +347,9 @@ paths: related resources should be returned. explode: false tags: - - Findings + - Finding security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -516,7 +516,7 @@ paths: tags: - Provider security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -544,7 +544,7 @@ paths: $ref: '#/components/schemas/ProviderCreateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '201': content: @@ -586,7 +586,7 @@ paths: tags: - Provider security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -622,7 +622,7 @@ paths: $ref: '#/components/schemas/PatchedProviderUpdateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -645,7 +645,7 @@ paths: tags: - Provider security: - - basicAuth: [] + - jwtAuth: [] responses: '202': content: @@ -670,7 +670,7 @@ paths: tags: - Provider security: - - basicAuth: [] + - jwtAuth: [] responses: '202': content: @@ -925,7 +925,7 @@ paths: tags: - Resource security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -983,7 +983,7 @@ paths: tags: - Resource security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1147,7 +1147,7 @@ paths: tags: - Scan security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1179,7 +1179,7 @@ paths: $ref: '#/components/schemas/ScanCreateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '201': content: @@ -1225,7 +1225,7 @@ paths: tags: - Scan security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1261,7 +1261,7 @@ paths: $ref: '#/components/schemas/PatchedScanUpdateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1344,7 +1344,7 @@ paths: tags: - Task security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1385,7 +1385,7 @@ paths: tags: - Task security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1409,7 +1409,7 @@ paths: tags: - Task security: - - basicAuth: [] + - jwtAuth: [] responses: '202': content: @@ -1431,9 +1431,8 @@ paths: items: type: string enum: - - inserted_at - - updated_at - name + - memberships description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -1517,7 +1516,7 @@ paths: tags: - Tenant security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1545,7 +1544,7 @@ paths: $ref: '#/components/schemas/TenantRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '201': content: @@ -1566,9 +1565,8 @@ paths: items: type: string enum: - - inserted_at - - updated_at - name + - memberships description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -1582,7 +1580,7 @@ paths: tags: - Tenant security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1618,7 +1616,7 @@ paths: $ref: '#/components/schemas/PatchedTenantRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1641,13 +1639,13 @@ paths: tags: - Tenant security: - - basicAuth: [] + - jwtAuth: [] responses: '204': description: No response body - /api/v1/tenants/{tenant_pk}/members: + /api/v1/tenants/{tenant_pk}/memberships: get: - operationId: tenants_members_list + operationId: tenants_memberships_list description: List the membership details of users in a tenant you are a part of. summary: List tenant memberships @@ -1714,7 +1712,7 @@ paths: tags: - Tenant security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1722,9 +1720,9 @@ paths: schema: $ref: '#/components/schemas/PaginatedMembershipList' description: '' - /api/v1/tenants/{tenant_pk}/members/{id}: + /api/v1/tenants/{tenant_pk}/memberships/{id}: delete: - operationId: tenants_members_destroy + operationId: tenants_memberships_destroy description: Delete the membership details of users in a tenant. You need to be one of the owners to delete a membership that is not yours. If you are the last owner of a tenant, you cannot delete your own membership. @@ -1746,10 +1744,69 @@ paths: tags: - Tenant security: - - basicAuth: [] + - jwtAuth: [] responses: '204': description: No response body + /api/v1/tokens: + post: + operationId: tokens_create + description: Obtain a token by providing valid credentials and an optional tenant + ID. + summary: Obtain a token + tags: + - Token + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TokenRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TokenResponse' + description: '' + /api/v1/tokens/refresh: + post: + operationId: tokens_refresh_create + description: Refresh an access token by providing a valid refresh token. + summary: Refresh a token + tags: + - Token + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TokenRefreshRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRefreshRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenRefreshRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TokenRefreshResponse' + description: '' /api/v1/users: post: operationId: users_create @@ -1771,7 +1828,7 @@ paths: $ref: '#/components/schemas/UserCreateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] - {} responses: '201': @@ -1781,6 +1838,45 @@ paths: $ref: '#/components/schemas/UserCreateResponse' description: '' /api/v1/users/{id}: + get: + operationId: users_retrieve + description: Fetch detailed information about an authenticated user. It only + allows using your own user ID. + summary: Retrieve a user's information + parameters: + - in: query + name: fields[User] + schema: + type: array + items: + type: string + enum: + - name + - email + - company_name + - date_joined + - memberships + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this user. + required: true + tags: + - User + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/UserResponse' + description: '' patch: operationId: users_partial_update description: Partially update the authenticated user's information. @@ -1808,7 +1904,7 @@ paths: $ref: '#/components/schemas/PatchedUserUpdateRequest' required: true security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1831,44 +1927,13 @@ paths: tags: - User security: - - basicAuth: [] + - jwtAuth: [] responses: '204': description: No response body - /api/v1/users/me: + /api/v1/users/{user_pk}/memberships: get: - operationId: users_me_retrieve - description: Fetch detailed information about the authenticated user. - summary: Retrieve the current user's information - parameters: - - in: query - name: fields[User] - schema: - type: array - items: - type: string - enum: - - name - - username - - email - - date_joined - description: endpoint return only specific fields in the response on a per-type - basis by including a fields[TYPE] query parameter. - explode: false - tags: - - User - security: - - basicAuth: [] - responses: - '200': - content: - application/vnd.api+json: - schema: - $ref: '#/components/schemas/UserResponse' - description: '' - /api/v1/users/me/memberships: - get: - operationId: users_me_memberships_list + operationId: users_memberships_list description: Retrieve a list of all user memberships with options for filtering by various criteria. summary: List user memberships @@ -1950,10 +2015,16 @@ paths: - date_joined - -date_joined explode: false + - in: path + name: user_pk + schema: + type: string + format: uuid + required: true tags: - User security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1961,9 +2032,9 @@ paths: schema: $ref: '#/components/schemas/PaginatedMembershipList' description: '' - /api/v1/users/me/memberships/{id}: + /api/v1/users/{user_pk}/memberships/{id}: get: - operationId: users_me_memberships_retrieve + operationId: users_memberships_retrieve description: Fetch detailed information about a specific user membership by their ID. summary: Retrieve membership data from the user @@ -1984,6 +2055,13 @@ paths: explode: false - in: path name: id + schema: + type: string + format: uuid + description: A UUID string identifying this membership. + required: true + - in: path + name: user_pk schema: type: string format: uuid @@ -1991,7 +2069,7 @@ paths: tags: - User security: - - basicAuth: [] + - jwtAuth: [] responses: '200': content: @@ -1999,6 +2077,38 @@ paths: schema: $ref: '#/components/schemas/MembershipResponse' description: '' + /api/v1/users/me: + get: + operationId: users_me_retrieve + description: Fetch detailed information about the authenticated user. + summary: Retrieve the current user's information + parameters: + - in: query + name: fields[User] + schema: + type: array + items: + type: string + enum: + - name + - email + - company_name + - date_joined + - memberships + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + tags: + - User + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/UserResponse' + description: '' components: schemas: DelayedTask: @@ -2168,7 +2278,7 @@ components: FindingTypeEnum: type: string enum: - - findings + - Findings Membership: type: object required: @@ -2213,7 +2323,7 @@ components: properties: id: type: string - format: uuid + format: uri type: type: string enum: @@ -2229,6 +2339,7 @@ components: - data description: The identifier of the related object. title: Resource Identifier + readOnly: true tenant: type: object properties: @@ -2237,7 +2348,7 @@ components: properties: id: type: string - format: uuid + format: uri type: type: string enum: @@ -2253,9 +2364,7 @@ components: - data description: The identifier of the related object. title: Resource Identifier - required: - - user - - tenant + readOnly: true MembershipResponse: type: object properties: @@ -2412,20 +2521,44 @@ components: attributes: type: object properties: - inserted_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true name: type: string minLength: 1 maxLength: 100 required: - name + relationships: + type: object + properties: + memberships: + 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: + - Membership + 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 Membership + title: Membership + readOnly: true required: - data PatchedUserUpdateRequest: @@ -2464,6 +2597,9 @@ components: format: email minLength: 1 maxLength: 254 + company_name: + type: string + maxLength: 150 required: - name - email @@ -3053,19 +3189,43 @@ components: attributes: type: object properties: - inserted_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true name: type: string maxLength: 100 required: - name + relationships: + type: object + properties: + memberships: + 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: + - Membership + 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 Membership + title: Membership + readOnly: true TenantRequest: type: object properties: @@ -3085,20 +3245,44 @@ components: attributes: type: object properties: - inserted_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true name: type: string minLength: 1 maxLength: 100 required: - name + relationships: + type: object + properties: + memberships: + 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: + - Membership + 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 Membership + title: Membership + readOnly: true required: - data TenantResponse: @@ -3112,6 +3296,164 @@ components: type: string enum: - Tenant + Token: + type: object + required: + - type + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TokenTypeEnum' + 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 + properties: + email: + type: string + format: email + writeOnly: true + password: + type: string + writeOnly: true + tenant_id: + type: string + format: uuid + writeOnly: true + description: If not provided, the tenant ID of the first membership + that was added to the user will be used. + refresh: + type: string + readOnly: true + access: + type: string + readOnly: true + required: + - email + - password + TokenRefresh: + type: object + required: + - type + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TokenRefreshTypeEnum' + 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 + properties: + refresh: + type: string + access: + type: string + readOnly: true + required: + - refresh + TokenRefreshRequest: + 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: + - TokenRefresh + attributes: + type: object + properties: + refresh: + type: string + minLength: 1 + access: + type: string + readOnly: true + minLength: 1 + required: + - refresh + required: + - data + TokenRefreshResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TokenRefresh' + required: + - data + TokenRefreshTypeEnum: + type: string + enum: + - TokenRefresh + TokenRequest: + 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: + - Token + attributes: + type: object + properties: + email: + type: string + format: email + writeOnly: true + minLength: 1 + password: + type: string + writeOnly: true + minLength: 1 + tenant_id: + type: string + format: uuid + writeOnly: true + description: If not provided, the tenant ID of the first membership + that was added to the user will be used. + refresh: + type: string + readOnly: true + minLength: 1 + access: + type: string + readOnly: true + minLength: 1 + required: + - email + - password + required: + - data + TokenResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Token' + required: + - data + TokenTypeEnum: + type: string + enum: + - Token Type2bbEnum: type: string enum: @@ -3151,22 +3493,52 @@ components: type: string maxLength: 150 minLength: 3 - username: - type: string - pattern: ^[\w.@+-]+$ - maxLength: 150 email: type: string format: email maxLength: 254 + company_name: + type: string + maxLength: 150 date_joined: type: string format: date-time readOnly: true required: - name - - username - email + relationships: + type: object + properties: + memberships: + 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: + - Membership + 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 Membership + title: Membership + readOnly: true UserCreate: type: object required: @@ -3186,10 +3558,6 @@ components: type: string maxLength: 150 minLength: 3 - username: - type: string - pattern: ^[\w.@+-]+$ - maxLength: 150 password: type: string writeOnly: true @@ -3197,9 +3565,10 @@ components: type: string format: email maxLength: 254 + company_name: + type: string required: - name - - username - password - email UserCreateRequest: @@ -3225,11 +3594,6 @@ components: type: string minLength: 3 maxLength: 150 - username: - type: string - minLength: 1 - pattern: ^[\w.@+-]+$ - maxLength: 150 password: type: string writeOnly: true @@ -3239,9 +3603,11 @@ components: format: email minLength: 1 maxLength: 254 + company_name: + type: string + minLength: 1 required: - name - - username - password - email required: @@ -3290,6 +3656,9 @@ components: type: string format: email maxLength: 254 + company_name: + type: string + maxLength: 150 required: - name - email @@ -3301,6 +3670,7 @@ components: required: - data securitySchemes: - basicAuth: + jwtAuth: type: http - scheme: basic + scheme: bearer + bearerFormat: JWT diff --git a/src/backend/api/tests/integration/test_authentication.py b/src/backend/api/tests/integration/test_authentication.py index df95f2258a..1000f76555 100644 --- a/src/backend/api/tests/integration/test_authentication.py +++ b/src/backend/api/tests/integration/test_authentication.py @@ -1,20 +1,19 @@ -import base64 - import pytest from django.urls import reverse from rest_framework.test import APIClient +from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header + @pytest.mark.django_db -def test_basic_authentication(providers_fixture, tenant_header): +def test_basic_authentication(): client = APIClient() - test_user = "test_user" + test_user = "test_email@prowler.com" test_password = "test_password" - credentials = base64.b64encode(f"{test_user}:{test_password}".encode()).decode() # Check that a 401 is returned when no basic authentication is provided - no_auth_response = client.get(reverse("provider-list"), headers=tenant_header) + no_auth_response = client.get(reverse("provider-list")) assert no_auth_response.status_code == 401 # Check that we can create a new user without any kind of authentication @@ -25,9 +24,8 @@ def test_basic_authentication(providers_fixture, tenant_header): "type": "User", "attributes": { "name": "test", - "username": test_user, + "email": test_user, "password": test_password, - "email": "thisisnotimportant@prowler.com", }, } }, @@ -36,10 +34,65 @@ def test_basic_authentication(providers_fixture, tenant_header): assert user_creation_response.status_code == 201 # Check that using our new user's credentials we can authenticate and get the providers + access_token, _ = get_api_tokens(client, test_user, test_password) + auth_headers = get_authorization_header(access_token) + auth_response = client.get( reverse("provider-list"), - headers=tenant_header, - HTTP_AUTHORIZATION=f"Basic {credentials}", + headers=auth_headers, ) assert auth_response.status_code == 200 - assert len(auth_response.json()["data"]) == len(providers_fixture) + + +@pytest.mark.django_db +def test_refresh_token(create_test_user, tenants_fixture): + client = APIClient() + + # Assert that we can obtain a new access token using the refresh one + access_token, refresh_token = get_api_tokens( + client, create_test_user.email, TEST_PASSWORD + ) + valid_refresh_response = client.post( + reverse("token-refresh"), + data={ + "data": { + "type": "TokenRefresh", + "attributes": {"refresh": refresh_token}, + } + }, + format="vnd.api+json", + ) + assert valid_refresh_response.status_code == 200 + assert ( + valid_refresh_response.json()["data"]["attributes"]["refresh"] != refresh_token + ) + + # Assert the former refresh token gets invalidated + invalid_refresh_response = client.post( + reverse("token-refresh"), + data={ + "data": { + "type": "TokenRefresh", + "attributes": {"refresh": refresh_token}, + } + }, + format="vnd.api+json", + ) + assert invalid_refresh_response.status_code == 400 + + # Assert that the new refresh token could be used + new_refresh_response = client.post( + reverse("token-refresh"), + data={ + "data": { + "type": "TokenRefresh", + "attributes": { + "refresh": valid_refresh_response.json()["data"]["attributes"][ + "refresh" + ] + }, + } + }, + format="vnd.api+json", + ) + assert new_refresh_response.status_code == 200 diff --git a/src/backend/api/tests/integration/test_tenants.py b/src/backend/api/tests/integration/test_tenants.py index 033e32349e..26b68d9e40 100644 --- a/src/backend/api/tests/integration/test_tenants.py +++ b/src/backend/api/tests/integration/test_tenants.py @@ -1,6 +1,8 @@ import pytest from django.urls import reverse +from conftest import TEST_USER, TEST_PASSWORD, get_api_tokens, get_authorization_header + @pytest.mark.django_db def test_check_resources_between_different_tenants( @@ -11,6 +13,16 @@ def test_check_resources_between_different_tenants( tenant1 = str(tenants_fixture[0].id) tenant2 = str(tenants_fixture[1].id) + tenant1_token, _ = get_api_tokens( + client, TEST_USER, TEST_PASSWORD, tenant_id=tenant1 + ) + tenant2_token, _ = get_api_tokens( + client, TEST_USER, TEST_PASSWORD, tenant_id=tenant2 + ) + + tenant1_headers = get_authorization_header(tenant1_token) + tenant2_headers = get_authorization_header(tenant2_token) + # Create a provider on tenant 1 provider_data = { "data": { @@ -26,7 +38,7 @@ def test_check_resources_between_different_tenants( reverse("provider-list"), data=provider_data, format="vnd.api+json", - HTTP_X_TENANT_ID=tenant1, + headers=tenant1_headers, ) assert provider1_response.status_code == 201 provider1_id = provider1_response.json()["data"]["id"] @@ -46,7 +58,7 @@ def test_check_resources_between_different_tenants( reverse("provider-list"), data=provider_data, format="vnd.api+json", - HTTP_X_TENANT_ID=tenant2, + headers=tenant2_headers, ) assert provider2_response.status_code == 201 provider2_id = provider2_response.json()["data"]["id"] @@ -54,12 +66,12 @@ def test_check_resources_between_different_tenants( # Try to get the provider from tenant 1 on tenant 2 and vice versa tenant1_response = client.get( reverse("provider-detail", kwargs={"pk": provider1_id}), - HTTP_X_TENANT_ID=tenant2, + headers=tenant2_headers, ) assert tenant1_response.status_code == 404 tenant2_response = client.get( reverse("provider-detail", kwargs={"pk": provider1_id}), - HTTP_X_TENANT_ID=tenant1, + headers=tenant1_headers, ) assert tenant2_response.status_code == 200 assert tenant2_response.json()["data"]["id"] == provider1_id @@ -68,12 +80,12 @@ def test_check_resources_between_different_tenants( tenant2_response = client.get( reverse("provider-detail", kwargs={"pk": provider2_id}), - HTTP_X_TENANT_ID=tenant1, + headers=tenant1_headers, ) assert tenant2_response.status_code == 404 tenant1_response = client.get( reverse("provider-detail", kwargs={"pk": provider2_id}), - HTTP_X_TENANT_ID=tenant2, + headers=tenant2_headers, ) assert tenant1_response.status_code == 200 assert tenant1_response.json()["data"]["id"] == provider2_id diff --git a/src/backend/api/tests/test_middleware.py b/src/backend/api/tests/test_middleware.py index 44e91bf200..a6c3faeded 100644 --- a/src/backend/api/tests/test_middleware.py +++ b/src/backend/api/tests/test_middleware.py @@ -2,8 +2,6 @@ from unittest.mock import patch, Mock, ANY import pytest -from api.middleware import extract_tenant_id - @pytest.mark.django_db @patch("logging.getLogger") @@ -33,10 +31,3 @@ def test_api_logger_middleware(mock_get_logger, client): ) assert isinstance(mock_logger.info.call_args[1]["extra"]["duration"], float) - - -def test_extract_tenant_id(): - mock_request = Mock() - extract_tenant_id(mock_request) - - mock_request.headers.get.assert_called_once_with("X-Tenant-ID") diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index e0d096602f..1720182e97 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -1,6 +1,5 @@ -import base64 from datetime import datetime -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, ANY import pytest from django.urls import reverse @@ -10,7 +9,6 @@ from api.models import User, Membership, Provider, Scan from api.rls import Tenant from conftest import ( API_JSON_CONTENT_TYPE, - NO_TENANT_HTTP_STATUS, TEST_USER, TEST_PASSWORD, ) @@ -33,15 +31,11 @@ class TestUserViewSet: def test_users_me(self, authenticated_client, create_test_user): response = authenticated_client.get(reverse("user-me")) assert response.status_code == status.HTTP_200_OK - assert ( - response.json()["data"]["attributes"]["username"] - == create_test_user.username - ) + assert response.json()["data"]["attributes"]["email"] == create_test_user.email def test_users_create(self, client): valid_user_payload = { "name": "test", - "username": "newuser", "password": "newpassword123", "email": "newuser@example.com", } @@ -49,13 +43,15 @@ class TestUserViewSet: reverse("user-list"), data=valid_user_payload, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert User.objects.filter(username="newuser").exists() - assert response.json()["data"]["attributes"]["username"] == "newuser" + assert User.objects.filter(email=valid_user_payload["email"]).exists() + assert ( + response.json()["data"]["attributes"]["email"] + == valid_user_payload["email"] + ) def test_users_invalid_create(self, client): invalid_user_payload = { "name": "test", - "username": "theusernameisfine", "password": "thepasswordisfine123", "email": "invalidemail", } @@ -82,21 +78,15 @@ class TestUserViewSet: # Fails NumericPasswordValidator (entirely numeric) "12345678", "00000000", - # Fails UserAttributeSimilarityValidator (too similar to username or email) - "thisisfine", - "thisisfine1", - "thisisafineemail", - "thisisafineemail@prowler.com", # Fails multiple validators "password1", # Common password and too similar to a common password - "thisisfine123", # Similar to username + "dev12345", # Similar to username ("querty12" * 9) + "a", # Too long, 73 characters ], ) def test_users_create_invalid_passwords(self, authenticated_client, password): invalid_user_payload = { "name": "test", - "username": "thisisfine", "password": password, "email": "thisisafineemail@prowler.com", } @@ -110,12 +100,12 @@ class TestUserViewSet: ) def test_users_partial_update(self, authenticated_client, create_test_user): - new_email = "updated@example.com" + new_company_name = "new company test" payload = { "data": { "type": "User", "id": str(create_test_user.id), - "attributes": {"email": new_email}, + "attributes": {"company_name": new_company_name}, }, } response = authenticated_client.patch( @@ -125,7 +115,7 @@ class TestUserViewSet: ) assert response.status_code == status.HTTP_200_OK create_test_user.refresh_from_db() - assert create_test_user.email == new_email + assert create_test_user.company_name == new_company_name def test_users_partial_update_invalid_content_type( self, authenticated_client, create_test_user @@ -150,7 +140,7 @@ class TestUserViewSet: self, authenticated_client, create_test_user ): another_user = User.objects.create_user( - username="otheruser", password="otherpassword", email="other@example.com" + password="otherpassword", email="other@example.com" ) new_email = "new@example.com" payload = { @@ -183,10 +173,9 @@ class TestUserViewSet: # Fails NumericPasswordValidator (entirely numeric) "12345678", "00000000", - # Fails UserAttributeSimilarityValidator (too similar to username or email) - "testing123", - "thisistesting", - "testing@gmail.com", + # Fails UserAttributeSimilarityValidator (too similar to email) + "dev12345", + "test@prowler.com", ], ) def test_users_partial_update_invalid_password( @@ -220,7 +209,7 @@ class TestUserViewSet: def test_users_destroy_invalid_user(self, authenticated_client, create_test_user): another_user = User.objects.create_user( - username="otheruser", password="otherpassword", email="other@example.com" + password="otherpassword", email="other@example.com" ) response = authenticated_client.delete( reverse("user-detail", kwargs={"pk": another_user.id}) @@ -231,7 +220,6 @@ class TestUserViewSet: @pytest.mark.parametrize( "attribute_key, attribute_value, error_field", [ - ("username", "", "username"), ("password", "", "password"), ("email", "invalidemail", "email"), ], @@ -241,7 +229,6 @@ class TestUserViewSet: ): invalid_payload = { "name": "test", - "username": "testuser", "password": "testpassword", "email": "test@example.com", } @@ -252,15 +239,6 @@ class TestUserViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST assert error_field in response.json()["errors"][0]["source"]["pointer"] - def test_users_create_existing_username(self, client, create_test_user): - payload = { - "username": create_test_user.username, - "password": "newpassword123", - "email": "newemail@example.com", - } - response = client.post(reverse("user-list"), data=payload, format="json") - assert response.status_code == status.HTTP_400_BAD_REQUEST - @pytest.mark.django_db class TestTenantViewSet: @@ -285,13 +263,11 @@ class TestTenantViewSet: _, tenant2 = tenants_fixture user2 = User.objects.create_user( name="testing2", - username="testing2", password=TEST_PASSWORD, email="testing2@gmail.com", ) user3 = User.objects.create_user( name="testing3", - username="testing3", password=TEST_PASSWORD, email="testing3@gmail.com", ) @@ -331,8 +307,12 @@ class TestTenantViewSet: reverse("tenant-list"), data=valid_tenant_payload, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert Tenant.objects.count() == 1 - assert Tenant.objects.get().name == valid_tenant_payload["name"] + # Two tenants from the fixture + the new one + assert Tenant.objects.count() == 3 + assert ( + response.json()["data"]["attributes"]["name"] + == valid_tenant_payload["name"] + ) def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload): response = authenticated_client.post( @@ -439,7 +419,6 @@ class TestTenantViewSet: self, authenticated_client, tenants_fixture, - tenant_header, filter_name, filter_value, expected_count, @@ -447,7 +426,6 @@ class TestTenantViewSet: response = authenticated_client.get( reverse("tenant-list"), {f"filter[{filter_name}]": filter_value}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK @@ -507,12 +485,16 @@ class TestTenantViewSet: _, tenant2 = tenants_fixture _, user3_membership = extra_users user3, membership3 = user3_membership - base_64_credentials = base64.b64encode( - f"{user3.username}:{TEST_PASSWORD}".encode() - ).decode() + token_response = authenticated_client.post( + reverse("token-obtain"), + data={"email": user3.email, "password": TEST_PASSWORD}, + format="json", + ) + access_token = token_response.json()["data"]["attributes"]["access"] + response = authenticated_client.get( reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}), - headers={"Authorization": f"Basic {base_64_credentials}"}, + headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_200_OK # User is a member and can only see its own membership @@ -523,7 +505,7 @@ class TestTenantViewSet: self, authenticated_client, tenants_fixture, extra_users ): tenant1, _ = tenants_fixture - membership = Membership.objects.get(tenant=tenant1, user__username=TEST_USER) + membership = Membership.objects.get(tenant=tenant1, user__email=TEST_USER) response = authenticated_client.delete( reverse( @@ -539,9 +521,7 @@ class TestTenantViewSet: ): # With extra_users, tenant2 has 2 owners _, tenant2 = tenants_fixture - user_membership = Membership.objects.get( - tenant=tenant2, user__username=TEST_USER - ) + user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER) response = authenticated_client.delete( reverse( "tenant-membership-detail", @@ -555,9 +535,7 @@ class TestTenantViewSet: self, authenticated_client, tenants_fixture ): _, tenant2 = tenants_fixture - user_membership = Membership.objects.get( - tenant=tenant2, user__username=TEST_USER - ) + user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER) response = authenticated_client.delete( reverse( "tenant-membership-detail", @@ -591,9 +569,7 @@ class TestTenantViewSet: user3, membership3 = user3_membership # Downgrade membership role manually - user_membership = Membership.objects.get( - tenant=tenant2, user__username=TEST_USER - ) + user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER) user_membership.role = Membership.RoleChoices.MEMBER user_membership.save() @@ -650,7 +626,7 @@ class TestMembershipViewSet: == membership["relationships"]["user"]["data"]["id"] ) - def test_memberships_invalid_retrieve(self, authenticated_client, tenant_header): + def test_memberships_invalid_retrieve(self, authenticated_client): user_id = authenticated_client.user.pk response = authenticated_client.get( reverse( @@ -754,36 +730,24 @@ class TestMembershipViewSet: @pytest.mark.django_db class TestProviderViewSet: - def test_providers_rls(self, authenticated_client): + def test_providers_list(self, authenticated_client, providers_fixture): response = authenticated_client.get(reverse("provider-list")) - assert response.status_code == NO_TENANT_HTTP_STATUS - - def test_providers_list( - self, authenticated_client, providers_fixture, tenant_header - ): - response = authenticated_client.get( - reverse("provider-list"), headers=tenant_header - ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(providers_fixture) - def test_providers_retrieve( - self, authenticated_client, providers_fixture, tenant_header - ): + def test_providers_retrieve(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture response = authenticated_client.get( reverse("provider-detail", kwargs={"pk": provider1.id}), - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["provider"] == provider1.provider assert response.json()["data"]["attributes"]["uid"] == provider1.uid assert response.json()["data"]["attributes"]["alias"] == provider1.alias - def test_providers_invalid_retrieve(self, authenticated_client, tenant_header): + def test_providers_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( - reverse("provider-detail", kwargs={"pk": "random_id"}), - headers=tenant_header, + reverse("provider-detail", kwargs={"pk": "random_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -806,14 +770,9 @@ class TestProviderViewSet: ] ), ) - def test_providers_create_valid( - self, authenticated_client, tenant_header, provider_json_payload - ): + def test_providers_create_valid(self, authenticated_client, provider_json_payload): response = authenticated_client.post( - reverse("provider-list"), - data=provider_json_payload, - format="json", - headers=tenant_header, + reverse("provider-list"), data=provider_json_payload, format="json" ) assert response.status_code == status.HTTP_201_CREATED assert Provider.objects.count() == 1 @@ -882,16 +841,12 @@ class TestProviderViewSet: def test_providers_invalid_create( self, authenticated_client, - tenant_header, provider_json_payload, error_code, error_pointer, ): response = authenticated_client.post( - reverse("provider-list"), - data=provider_json_payload, - format="json", - headers=tenant_header, + reverse("provider-list"), data=provider_json_payload, format="json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == error_code @@ -900,9 +855,7 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) - def test_providers_partial_update( - self, authenticated_client, providers_fixture, tenant_header - ): + def test_providers_partial_update(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture new_alias = "This is the new name" payload = { @@ -916,25 +869,23 @@ class TestProviderViewSet: reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK provider1.refresh_from_db() assert provider1.alias == new_alias def test_providers_partial_update_invalid_content_type( - self, authenticated_client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture ): provider1, *_ = providers_fixture response = authenticated_client.patch( reverse("provider-detail", kwargs={"pk": provider1.id}), data={}, - headers=tenant_header, ) assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE def test_providers_partial_update_invalid_content( - self, authenticated_client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture ): provider1, *_ = providers_fixture new_name = "This is the new name" @@ -943,7 +894,6 @@ class TestProviderViewSet: reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -958,7 +908,6 @@ class TestProviderViewSet: self, authenticated_client, providers_fixture, - tenant_header, attribute_key, attribute_value, ): @@ -974,13 +923,12 @@ class TestProviderViewSet: reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @patch("api.v1.views.delete_provider_task.delay") def test_providers_delete( - self, mock_delete_task, authenticated_client, providers_fixture, tenant_header + self, mock_delete_task, authenticated_client, providers_fixture ): task_mock = Mock() task_mock.id = "12345" @@ -988,20 +936,18 @@ class TestProviderViewSet: provider1, *_ = providers_fixture response = authenticated_client.delete( - reverse("provider-detail", kwargs={"pk": provider1.id}), - headers=tenant_header, + reverse("provider-detail", kwargs={"pk": provider1.id}) ) assert response.status_code == status.HTTP_202_ACCEPTED mock_delete_task.assert_called_once_with( - provider_id=str(provider1.id), tenant_id=tenant_header["X-Tenant-ID"] + provider_id=str(provider1.id), tenant_id=ANY ) assert "Content-Location" in response.headers assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}" - def test_providers_delete_invalid(self, authenticated_client, tenant_header): + def test_providers_delete_invalid(self, authenticated_client): response = authenticated_client.delete( - reverse("provider-detail", kwargs={"pk": "random_id"}), - headers=tenant_header, + reverse("provider-detail", kwargs={"pk": "random_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -1011,7 +957,6 @@ class TestProviderViewSet: mock_provider_connection, authenticated_client, providers_fixture, - tenant_header, ): task_mock = Mock() task_mock.id = "12345" @@ -1023,22 +968,20 @@ class TestProviderViewSet: assert provider1.connection_last_checked_at is None response = authenticated_client.post( - reverse("provider-connection", kwargs={"pk": provider1.id}), - headers=tenant_header, + reverse("provider-connection", kwargs={"pk": provider1.id}) ) assert response.status_code == status.HTTP_202_ACCEPTED mock_provider_connection.assert_called_once_with( - provider_id=str(provider1.id), tenant_id=tenant_header["X-Tenant-ID"] + provider_id=str(provider1.id), tenant_id=ANY ) assert "Content-Location" in response.headers assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}" def test_providers_connection_invalid_provider( - self, authenticated_client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture ): response = authenticated_client.post( - reverse("provider-connection", kwargs={"pk": "random_id"}), - headers=tenant_header, + reverse("provider-connection", kwargs={"pk": "random_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -1064,7 +1007,6 @@ class TestProviderViewSet: self, authenticated_client, providers_fixture, - tenant_header, filter_name, filter_value, expected_count, @@ -1072,7 +1014,6 @@ class TestProviderViewSet: response = authenticated_client.get( reverse("provider-list"), {f"filter[{filter_name}]": filter_value}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK @@ -1087,13 +1028,10 @@ class TestProviderViewSet: ] ), ) - def test_providers_filters_invalid( - self, authenticated_client, tenant_header, filter_name - ): + def test_providers_filters_invalid(self, authenticated_client, filter_name): response = authenticated_client.get( reverse("provider-list"), {f"filter[{filter_name}]": "whatever"}, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1110,31 +1048,30 @@ class TestProviderViewSet: ] ), ) - def test_providers_sort(self, authenticated_client, tenant_header, sort_field): + def test_providers_sort(self, authenticated_client, sort_field): response = authenticated_client.get( - reverse("provider-list"), {"sort": sort_field}, headers=tenant_header + reverse("provider-list"), {"sort": sort_field} ) assert response.status_code == status.HTTP_200_OK - def test_providers_sort_invalid(self, authenticated_client, tenant_header): + def test_providers_sort_invalid(self, authenticated_client): response = authenticated_client.get( - reverse("provider-list"), {"sort": "invalid"}, headers=tenant_header + reverse("provider-list"), {"sort": "invalid"} ) assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db class TestScanViewSet: - def test_scans_list(self, authenticated_client, scans_fixture, tenant_header): - response = authenticated_client.get(reverse("scan-list"), headers=tenant_header) + def test_scans_list(self, authenticated_client, scans_fixture): + response = authenticated_client.get(reverse("scan-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(scans_fixture) - def test_scans_retrieve(self, authenticated_client, scans_fixture, tenant_header): + def test_scans_retrieve(self, authenticated_client, scans_fixture): scan1, *_ = scans_fixture response = authenticated_client.get( - reverse("scan-detail", kwargs={"pk": scan1.id}), - headers=tenant_header, + reverse("scan-detail", kwargs={"pk": scan1.id}) ) assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["name"] == scan1.name @@ -1142,10 +1079,9 @@ class TestScanViewSet: "id" ] == str(scan1.provider.id) - def test_scans_invalid_retrieve(self, authenticated_client, tenant_header): + def test_scans_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( - reverse("scan-detail", kwargs={"pk": "random_id"}), - headers=tenant_header, + reverse("scan-detail", kwargs={"pk": "random_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -1194,7 +1130,6 @@ class TestScanViewSet: def test_scans_create_valid( self, authenticated_client, - tenant_header, scan_json_payload, expected_scanner_args, providers_fixture, @@ -1211,7 +1146,6 @@ class TestScanViewSet: reverse("scan-list"), data=scan_json_payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_202_ACCEPTED @@ -1248,7 +1182,6 @@ class TestScanViewSet: def test_scans_invalid_create( self, authenticated_client, - tenant_header, scan_json_payload, providers_fixture, error_code, @@ -1261,7 +1194,6 @@ class TestScanViewSet: reverse("scan-list"), data=scan_json_payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == error_code @@ -1269,9 +1201,7 @@ class TestScanViewSet: response.json()["errors"][0]["source"]["pointer"] == "/data/attributes/name" ) - def test_scans_partial_update( - self, authenticated_client, scans_fixture, tenant_header - ): + def test_scans_partial_update(self, authenticated_client, scans_fixture): scan1, *_ = scans_fixture new_name = "Updated Scan Name" payload = { @@ -1285,25 +1215,23 @@ class TestScanViewSet: reverse("scan-detail", kwargs={"pk": scan1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK scan1.refresh_from_db() assert scan1.name == new_name def test_scans_partial_update_invalid_content_type( - self, authenticated_client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture ): scan1, *_ = scans_fixture response = authenticated_client.patch( reverse("scan-detail", kwargs={"pk": scan1.id}), data={}, - headers=tenant_header, ) assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE def test_scans_partial_update_invalid_content( - self, authenticated_client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture ): scan1, *_ = scans_fixture new_name = "Updated Scan Name" @@ -1312,7 +1240,6 @@ class TestScanViewSet: reverse("scan-detail", kwargs={"pk": scan1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1341,7 +1268,6 @@ class TestScanViewSet: self, authenticated_client, scans_fixture, - tenant_header, filter_name, filter_value, expected_count, @@ -1349,7 +1275,6 @@ class TestScanViewSet: response = authenticated_client.get( reverse("scan-list"), {f"filter[{filter_name}]": filter_value}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK @@ -1362,30 +1287,24 @@ class TestScanViewSet: "invalid", ], ) - def test_scans_filters_invalid( - self, authenticated_client, tenant_header, filter_name - ): + def test_scans_filters_invalid(self, authenticated_client, filter_name): response = authenticated_client.get( reverse("scan-list"), {f"filter[{filter_name}]": "invalid_value"}, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_scan_filter_by_provider_id_exact( - self, authenticated_client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture ): response = authenticated_client.get( reverse("scan-list"), {"filter[provider]": scans_fixture[0].provider.id}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 - def test_scan_filter_by_provider_id_in( - self, authenticated_client, scans_fixture, tenant_header - ): + def test_scan_filter_by_provider_id_in(self, authenticated_client, scans_fixture): response = authenticated_client.get( reverse("scan-list"), { @@ -1394,7 +1313,6 @@ class TestScanViewSet: scans_fixture[1].provider.id, ] }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 @@ -1408,31 +1326,26 @@ class TestScanViewSet: "updated_at", ], ) - def test_scans_sort(self, authenticated_client, tenant_header, sort_field): - response = authenticated_client.get( - reverse("scan-list"), {"sort": sort_field}, headers=tenant_header - ) + def test_scans_sort(self, authenticated_client, sort_field): + response = authenticated_client.get(reverse("scan-list"), {"sort": sort_field}) assert response.status_code == status.HTTP_200_OK - def test_scans_sort_invalid(self, authenticated_client, tenant_header): - response = authenticated_client.get( - reverse("scan-list"), {"sort": "invalid"}, headers=tenant_header - ) + def test_scans_sort_invalid(self, authenticated_client): + response = authenticated_client.get(reverse("scan-list"), {"sort": "invalid"}) assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db class TestTaskViewSet: - def test_tasks_list(self, authenticated_client, tasks_fixture, tenant_header): - response = authenticated_client.get(reverse("task-list"), headers=tenant_header) + def test_tasks_list(self, authenticated_client, tasks_fixture): + response = authenticated_client.get(reverse("task-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(tasks_fixture) - def test_tasks_retrieve(self, authenticated_client, tasks_fixture, tenant_header): + def test_tasks_retrieve(self, authenticated_client, tasks_fixture): task1, *_ = tasks_fixture response = authenticated_client.get( reverse("task-detail", kwargs={"pk": task1.id}), - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert ( @@ -1440,36 +1353,32 @@ class TestTaskViewSet: == task1.task_runner_task.task_name ) - def test_tasks_invalid_retrieve(self, authenticated_client, tenant_header): + def test_tasks_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( - reverse("task-detail", kwargs={"pk": "invalid_id"}), headers=tenant_header + reverse("task-detail", kwargs={"pk": "invalid_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND @patch("api.v1.views.AsyncResult", return_value=Mock()) - def test_tasks_revoke( - self, mock_async_result, authenticated_client, tasks_fixture, tenant_header - ): + def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture): _, task2 = tasks_fixture response = authenticated_client.delete( - reverse("task-detail", kwargs={"pk": task2.id}), headers=tenant_header + reverse("task-detail", kwargs={"pk": task2.id}) ) assert response.status_code == status.HTTP_202_ACCEPTED assert response.headers["Content-Location"] == f"/api/v1/tasks/{task2.id}" mock_async_result.return_value.revoke.assert_called_once() - def test_tasks_invalid_revoke(self, authenticated_client, tenant_header): + def test_tasks_invalid_revoke(self, authenticated_client): response = authenticated_client.delete( - reverse("task-detail", kwargs={"pk": "invalid_id"}), headers=tenant_header + reverse("task-detail", kwargs={"pk": "invalid_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND - def test_tasks_revoke_invalid_status( - self, authenticated_client, tasks_fixture, tenant_header - ): + def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture): task1, _ = tasks_fixture response = authenticated_client.delete( - reverse("task-detail", kwargs={"pk": task1.id}), headers=tenant_header + reverse("task-detail", kwargs={"pk": task1.id}) ) # Task status is SUCCESS assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1477,19 +1386,13 @@ class TestTaskViewSet: @pytest.mark.django_db class TestResourceViewSet: - def test_resources_list_none(self, authenticated_client, tenant_header): - response = authenticated_client.get( - reverse("resource-list"), headers=tenant_header - ) + def test_resources_list_none(self, authenticated_client): + response = authenticated_client.get(reverse("resource-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 - def test_resources_list( - self, authenticated_client, resources_fixture, tenant_header - ): - response = authenticated_client.get( - reverse("resource-list"), headers=tenant_header - ) + def test_resources_list(self, authenticated_client, resources_fixture): + response = authenticated_client.get(reverse("resource-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(resources_fixture) assert ( @@ -1544,7 +1447,6 @@ class TestResourceViewSet: self, authenticated_client, resources_fixture, - tenant_header, filter_name, filter_value, expected_count, @@ -1552,14 +1454,13 @@ class TestResourceViewSet: response = authenticated_client.get( reverse("resource-list"), {f"filter[{filter_name}]": filter_value}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count def test_resource_filter_by_provider_id_in( - self, authenticated_client, resources_fixture, tenant_header + self, authenticated_client, resources_fixture ): response = authenticated_client.get( reverse("resource-list"), @@ -1569,7 +1470,6 @@ class TestResourceViewSet: resources_fixture[1].provider.id, ] }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 @@ -1583,13 +1483,10 @@ class TestResourceViewSet: ] ), ) - def test_resources_filters_invalid( - self, authenticated_client, tenant_header, filter_name - ): + def test_resources_filters_invalid(self, authenticated_client, filter_name): response = authenticated_client.get( reverse("resource-list"), {f"filter[{filter_name}]": "whatever"}, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1606,15 +1503,15 @@ class TestResourceViewSet: "updated_at", ], ) - def test_resources_sort(self, authenticated_client, tenant_header, sort_field): + def test_resources_sort(self, authenticated_client, sort_field): response = authenticated_client.get( - reverse("resource-list"), {"sort": sort_field}, headers=tenant_header + reverse("resource-list"), {"sort": sort_field} ) assert response.status_code == status.HTTP_200_OK - def test_resources_sort_invalid(self, authenticated_client, tenant_header): + def test_resources_sort_invalid(self, authenticated_client): response = authenticated_client.get( - reverse("resource-list"), {"sort": "invalid"}, headers=tenant_header + reverse("resource-list"), {"sort": "invalid"} ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == "invalid" @@ -1623,13 +1520,10 @@ class TestResourceViewSet: response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid" ) - def test_resources_retrieve( - self, authenticated_client, resources_fixture, tenant_header - ): + def test_resources_retrieve(self, authenticated_client, resources_fixture): resource_1, *_ = resources_fixture response = authenticated_client.get( reverse("resource-detail", kwargs={"pk": resource_1.id}), - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["uid"] == resource_1.uid @@ -1639,27 +1533,22 @@ class TestResourceViewSet: assert response.json()["data"]["attributes"]["type"] == resource_1.type assert response.json()["data"]["attributes"]["tags"] == resource_1.get_tags() - def test_resources_invalid_retrieve(self, authenticated_client, tenant_header): + def test_resources_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( reverse("resource-detail", kwargs={"pk": "random_id"}), - headers=tenant_header, ) assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.django_db class TestFindingViewSet: - def test_findings_list_none(self, authenticated_client, tenant_header): - response = authenticated_client.get( - reverse("finding-list"), headers=tenant_header - ) + def test_findings_list_none(self, authenticated_client): + response = authenticated_client.get(reverse("finding-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 - def test_findings_list(self, authenticated_client, findings_fixture, tenant_header): - response = authenticated_client.get( - reverse("finding-list"), headers=tenant_header - ) + def test_findings_list(self, authenticated_client, findings_fixture): + response = authenticated_client.get(reverse("finding-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(findings_fixture) assert ( @@ -1708,7 +1597,6 @@ class TestFindingViewSet: self, authenticated_client, findings_fixture, - tenant_header, filter_name, filter_value, expected_count, @@ -1716,28 +1604,22 @@ class TestFindingViewSet: response = authenticated_client.get( reverse("finding-list"), {f"filter[{filter_name}]": filter_value}, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count - def test_finding_filter_by_scan_id( - self, authenticated_client, findings_fixture, tenant_header - ): + def test_finding_filter_by_scan_id(self, authenticated_client, findings_fixture): response = authenticated_client.get( reverse("finding-list"), { "filter[scan]": findings_fixture[0].scan.id, }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 - def test_finding_filter_by_scan_id_in( - self, authenticated_client, findings_fixture, tenant_header - ): + def test_finding_filter_by_scan_id_in(self, authenticated_client, findings_fixture): response = authenticated_client.get( reverse("finding-list"), { @@ -1746,26 +1628,22 @@ class TestFindingViewSet: findings_fixture[1].scan.id, ] }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 - def test_finding_filter_by_provider( - self, authenticated_client, findings_fixture, tenant_header - ): + def test_finding_filter_by_provider(self, authenticated_client, findings_fixture): response = authenticated_client.get( reverse("finding-list"), { "filter[provider]": findings_fixture[0].scan.provider.id, }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 def test_finding_filter_by_provider_id_in( - self, authenticated_client, findings_fixture, tenant_header + self, authenticated_client, findings_fixture ): response = authenticated_client.get( reverse("finding-list"), @@ -1775,7 +1653,6 @@ class TestFindingViewSet: findings_fixture[1].scan.provider.id, ] }, - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 @@ -1789,13 +1666,10 @@ class TestFindingViewSet: ] ), ) - def test_findings_filters_invalid( - self, authenticated_client, tenant_header, filter_name - ): + def test_findings_filters_invalid(self, authenticated_client, filter_name): response = authenticated_client.get( reverse("finding-list"), {f"filter[{filter_name}]": "whatever"}, - headers=tenant_header, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1809,15 +1683,15 @@ class TestFindingViewSet: "updated_at", ], ) - def test_findings_sort(self, authenticated_client, tenant_header, sort_field): + def test_findings_sort(self, authenticated_client, sort_field): response = authenticated_client.get( - reverse("finding-list"), {"sort": sort_field}, headers=tenant_header + reverse("finding-list"), {"sort": sort_field} ) assert response.status_code == status.HTTP_200_OK - def test_findings_sort_invalid(self, authenticated_client, tenant_header): + def test_findings_sort_invalid(self, authenticated_client): response = authenticated_client.get( - reverse("finding-list"), {"sort": "invalid"}, headers=tenant_header + reverse("finding-list"), {"sort": "invalid"} ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == "invalid" @@ -1826,13 +1700,10 @@ class TestFindingViewSet: response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid" ) - def test_findings_retrieve( - self, authenticated_client, findings_fixture, tenant_header - ): + def test_findings_retrieve(self, authenticated_client, findings_fixture): finding_1, *_ = findings_fixture response = authenticated_client.get( reverse("finding-detail", kwargs={"pk": finding_1.id}), - headers=tenant_header, ) assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["status"] == finding_1.status @@ -1851,9 +1722,8 @@ class TestFindingViewSet: "id" ] == str(finding_1.resources.first().id) - def test_findings_invalid_retrieve(self, authenticated_client, tenant_header): + def test_findings_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( reverse("finding-detail", kwargs={"pk": "random_id"}), - headers=tenant_header, ) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/backend/api/v1/serializers.py b/src/backend/api/v1/serializers.py index 7858b6abbd..10888c0b4c 100644 --- a/src/backend/api/v1/serializers.py +++ b/src/backend/api/v1/serializers.py @@ -1,10 +1,15 @@ import json +from django.conf import settings +from django.contrib.auth import authenticate from django.contrib.auth.password_validation import validate_password from drf_spectacular.utils import extend_schema_field +from jwt.exceptions import InvalidKeyError from rest_framework_json_api import serializers from rest_framework_json_api.relations import SerializerMethodResourceRelatedField from rest_framework_json_api.serializers import ValidationError +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken from api.models import ( StateChoices, @@ -24,6 +29,100 @@ from api.rls import Tenant from api.utils import merge_dicts +# Tokens + + +class TokenSerializer(serializers.Serializer): + email = serializers.EmailField(write_only=True) + password = serializers.CharField(write_only=True) + tenant_id = serializers.UUIDField( + write_only=True, + required=False, + help_text="If not provided, the tenant ID of the first membership that was added" + " to the user will be used.", + ) + + # Output tokens + refresh = serializers.CharField(read_only=True) + access = serializers.CharField(read_only=True) + + class JSONAPIMeta: + resource_name = "Token" + + def validate(self, attrs): + email = attrs.get("email") + password = attrs.get("password") + tenant_id = str(attrs.get("tenant_id", "")) + + # Authenticate user + user = authenticate(username=email, password=password) + if user is None: + raise serializers.ValidationError("Invalid credentials") + + if tenant_id: + if not user.is_member_of_tenant(tenant_id): + raise serializers.ValidationError( + "Tenant does not exist or user is not a member." + ) + else: + first_membership = user.memberships.order_by("date_joined").first() + if first_membership is None: + raise serializers.ValidationError("User has no memberships.") + tenant_id = str(first_membership.tenant_id) + + # Generate tokens + try: + refresh = RefreshToken.for_user(user) + refresh["tenant_id"] = tenant_id + access = refresh.access_token + except InvalidKeyError: + # Handle invalid key error + raise serializers.ValidationError( + { + "detail": "Token generation failed due to invalid key configuration. Provide valid " + "DJANGO_TOKEN_SIGNING_KEY and DJANGO_TOKEN_VERIFYING_KEY in the environment." + } + ) + except Exception as e: + raise serializers.ValidationError({"detail": str(e)}) + + return {"refresh": str(refresh), "access": str(access)} + + +class TokenRefreshSerializer(serializers.Serializer): + refresh = serializers.CharField() + + # Output token + access = serializers.CharField(read_only=True) + + class JSONAPIMeta: + resource_name = "TokenRefresh" + + def validate(self, attrs): + refresh_token = attrs.get("refresh") + + try: + # Validate the refresh token + refresh = RefreshToken(refresh_token) + # Generate new access token + access_token = refresh.access_token + + if settings.SIMPLE_JWT["ROTATE_REFRESH_TOKENS"]: + if settings.SIMPLE_JWT["BLACKLIST_AFTER_ROTATION"]: + try: + refresh.blacklist() + except AttributeError: + pass + + refresh.set_jti() + refresh.set_exp() + refresh.set_iat() + + return {"access": str(access_token), "refresh": str(refresh)} + except TokenError: + raise serializers.ValidationError({"refresh": "Invalid or expired token"}) + + # Base @@ -67,15 +166,16 @@ class UserSerializer(BaseSerializerV1): class Meta: model = User - fields = ["id", "name", "username", "email", "date_joined", "memberships"] + fields = ["id", "name", "email", "company_name", "date_joined", "memberships"] class UserCreateSerializer(BaseWriteSerializer): password = serializers.CharField(write_only=True) + company_name = serializers.CharField(required=False) class Meta: model = User - fields = ["name", "username", "password", "email"] + fields = ["name", "password", "email", "company_name"] def validate_password(self, value): user = User(**{k: v for k, v in self.initial_data.items() if k != "type"}) @@ -97,7 +197,7 @@ class UserUpdateSerializer(BaseWriteSerializer): class Meta: model = User - fields = ["id", "name", "password", "email"] + fields = ["id", "name", "password", "email", "company_name"] extra_kwargs = { "id": {"read_only": True}, } diff --git a/src/backend/api/v1/urls.py b/src/backend/api/v1/urls.py index 685f9064c8..0e7658b584 100644 --- a/src/backend/api/v1/urls.py +++ b/src/backend/api/v1/urls.py @@ -3,6 +3,8 @@ from drf_spectacular.views import SpectacularRedocView from rest_framework_nested import routers from api.v1.views import ( + CustomTokenObtainView, + CustomTokenRefreshView, SchemaView, UserViewSet, TenantViewSet, @@ -34,6 +36,8 @@ users_router = routers.NestedSimpleRouter(router, r"users", lookup="user") users_router.register(r"memberships", MembershipViewSet, basename="user-membership") urlpatterns = [ + path("tokens", CustomTokenObtainView.as_view(), name="token-obtain"), + path("tokens/refresh", CustomTokenRefreshView.as_view(), name="token-refresh"), path("", include(router.urls)), path("", include(tenants_router.urls)), path("", include(users_router.urls)), diff --git a/src/backend/api/v1/views.py b/src/backend/api/v1/views.py index e29a63194e..f562d5d858 100644 --- a/src/backend/api/v1/views.py +++ b/src/backend/api/v1/views.py @@ -6,17 +6,16 @@ from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from drf_spectacular.settings import spectacular_settings -from drf_spectacular.utils import ( - OpenApiParameter, - OpenApiTypes, -) -from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_spectacular.utils import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.views import SpectacularAPIView from rest_framework import status, permissions from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed, NotFound, PermissionDenied -from rest_framework.generics import get_object_or_404 +from rest_framework.generics import get_object_or_404, GenericAPIView from rest_framework_json_api.views import Response +from rest_framework_simplejwt.exceptions import InvalidToken +from rest_framework_simplejwt.exceptions import TokenError from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseViewSet from api.filters import ( @@ -32,6 +31,8 @@ from api.models import User, Membership, Provider, Scan, Task, Resource, Finding from api.rls import Tenant from api.uuid_utils import datetime_to_uuid7 from api.v1.serializers import ( + TokenSerializer, + TokenRefreshSerializer, UserSerializer, UserCreateSerializer, UserUpdateSerializer, @@ -56,6 +57,55 @@ CACHE_DECORATOR = cache_control( ) +@extend_schema( + tags=["Token"], + summary="Obtain a token", + description="Obtain a token by providing valid credentials and an optional tenant ID.", +) +class CustomTokenObtainView(GenericAPIView): + resource_name = "Token" + serializer_class = TokenSerializer + http_method_names = ["post"] + + def post(self, request): + serializer = TokenSerializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + + return Response( + data={"type": "Token", "attributes": serializer.validated_data}, + status=status.HTTP_200_OK, + ) + + +@extend_schema( + tags=["Token"], + summary="Refresh a token", + description="Refresh an access token by providing a valid refresh token. Former refresh tokens are invalidated " + "when a new one is issued.", +) +class CustomTokenRefreshView(GenericAPIView): + resource_name = "TokenRefresh" + serializer_class = TokenRefreshSerializer + http_method_names = ["post"] + + def post(self, request): + serializer = TokenRefreshSerializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + + return Response( + data={"type": "TokenRefresh", "attributes": serializer.validated_data}, + status=status.HTTP_200_OK, + ) + + @extend_schema(exclude=True) class SchemaView(SpectacularAPIView): serializer_class = None @@ -140,6 +190,12 @@ class UserViewSet(BaseViewSet): ) serializer.is_valid(raise_exception=True) user = serializer.save() + tenant = Tenant.objects.create( + name=f"{user.email.split('@')[0]} default tenant" + ) + Membership.objects.create( + user=user, tenant=tenant, role=Membership.RoleChoices.OWNER + ) return Response(data=UserSerializer(user).data, status=status.HTTP_201_CREATED) def partial_update(self, request, *args, **kwargs): @@ -393,7 +449,7 @@ class ProviderViewSet(BaseRLSViewSet): def connection(self, request, pk=None): get_object_or_404(Provider, pk=pk) task = check_provider_connection_task.delay( - provider_id=pk, tenant_id=request.headers.get("X-Tenant-ID") + provider_id=pk, tenant_id=request.tenant_id ) serializer = DelayedTaskSerializer(task) return Response( @@ -406,9 +462,7 @@ class ProviderViewSet(BaseRLSViewSet): def destroy(self, request, *args, pk=None, **kwargs): get_object_or_404(Provider, pk=pk) - task = delete_provider_task.delay( - provider_id=pk, tenant_id=request.headers.get("X-Tenant-ID") - ) + task = delete_provider_task.delay(provider_id=pk, tenant_id=request.tenant_id) serializer = DelayedTaskSerializer(task) return Response( data=serializer.data, diff --git a/src/backend/config/django/base.py b/src/backend/config/django/base.py index 8d5bd912a7..78abc6df49 100644 --- a/src/backend/config/django/base.py +++ b/src/backend/config/django/base.py @@ -27,6 +27,7 @@ INSTALLED_APPS = [ "django_guid", "rest_framework_json_api", "django_celery_results", + "rest_framework_simplejwt.token_blacklist", ] MIDDLEWARE = [ @@ -64,6 +65,9 @@ TEMPLATES = [ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), "PAGE_SIZE": 10, "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler", "DEFAULT_PAGINATION_CLASS": "drf_spectacular_jsonapi.schemas.pagination.JsonApiPageNumberPagination", @@ -145,11 +149,15 @@ SIMPLE_JWT = { "REFRESH_TOKEN_LIFETIME": timedelta( minutes=env.int("DJANGO_REFRESH_TOKEN_LIFETIME", 60 * 24) ), - "ROTATE_REFRESH_TOKENS": False, + "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": True, - "ALGORITHM": "HS256", - "SIGNING_KEY": env.str("DJANGO_TOKEN_SIGNING_KEY", "S3cret"), + # TODO Add environment variable for this + "ALGORITHM": "RS256", + "SIGNING_KEY": env.str("DJANGO_TOKEN_SIGNING_KEY", ""), + "VERIFYING_KEY": env.str("DJANGO_TOKEN_VERIFYING_KEY", ""), "AUTH_HEADER_TYPES": ("Bearer",), + "TOKEN_OBTAIN_SERIALIZER": "api.serializers.TokenSerializer", + "TOKEN_REFRESH_SERIALIZER": "api.serializers.TokenRefreshSerializer", } # Internationalization diff --git a/src/backend/config/django/testing.py b/src/backend/config/django/testing.py index 7846301f4e..ae374b6ef9 100644 --- a/src/backend/config/django/testing.py +++ b/src/backend/config/django/testing.py @@ -19,3 +19,8 @@ DATABASES = { DATABASE_ROUTERS = [] TESTING = True + + +# JWT + +SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405 diff --git a/src/backend/conftest.py b/src/backend/conftest.py index 43e4d91e53..ff62565e01 100644 --- a/src/backend/conftest.py +++ b/src/backend/conftest.py @@ -1,9 +1,9 @@ -import base64 import logging import pytest from django.conf import settings from django.db import connections as django_connections, connection as django_connection +from django.urls import reverse from django_celery_results.models import TaskResult from prowler.lib.outputs.finding import Severity, Status from rest_framework import status @@ -23,13 +23,12 @@ from api.models import ( Membership, ) from api.rls import Tenant +from api.v1.serializers import TokenSerializer API_JSON_CONTENT_TYPE = "application/vnd.api+json" NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED -TEST_USER = "testing_user" +TEST_USER = "dev@prowler.com" TEST_PASSWORD = "testing_psswd" -TEST_CREDENTIALS = f"{TEST_USER}:{TEST_PASSWORD}" -TEST_BASE64_CREDENTIALS = base64.b64encode(TEST_CREDENTIALS.encode()).decode() @pytest.fixture(scope="module") @@ -72,24 +71,33 @@ def create_test_user(django_db_setup, django_db_blocker): with django_db_blocker.unblock(): user = User.objects.create_user( name="testing", - username=TEST_USER, + email=TEST_USER, password=TEST_PASSWORD, - email="testing@gmail.com", ) return user @pytest.fixture -def authenticated_client(create_test_user, client): +def authenticated_client(create_test_user, tenants_fixture, client): client.user = create_test_user - client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}" + serializer = TokenSerializer( + data={"type": "Token", "email": TEST_USER, "password": TEST_PASSWORD} + ) + serializer.is_valid() + access_token = serializer.validated_data["access"] + client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}" return client @pytest.fixture -def authenticated_api_client(create_test_user): +def authenticated_api_client(create_test_user, tenants_fixture): client = APIClient() - client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}" + serializer = TokenSerializer( + data={"type": "Token", "email": TEST_USER, "password": TEST_PASSWORD} + ) + serializer.is_valid() + access_token = serializer.validated_data["access"] + client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}" return client @@ -331,6 +339,29 @@ def findings_fixture(scans_fixture, resources_fixture): return finding1, finding2 -@pytest.fixture -def tenant_header(tenants_fixture): - return {"X-Tenant-ID": str(tenants_fixture[0].id)} +def get_api_tokens( + api_client, user_email: str, user_password: str, tenant_id: str = None +) -> tuple[str, str]: + json_body = { + "data": { + "type": "Token", + "attributes": { + "email": user_email, + "password": user_password, + }, + } + } + if tenant_id is not None: + json_body["data"]["attributes"]["tenant_id"] = tenant_id + response = api_client.post( + reverse("token-obtain"), + data=json_body, + format="vnd.api+json", + ) + return response.json()["data"]["attributes"]["access"], response.json()["data"][ + "attributes" + ]["refresh"] + + +def get_authorization_header(access_token: str) -> dict: + return {"Authorization": f"Bearer {access_token}"}