diff --git a/.env.example b/.env.example index af873ede77..753ebedf0a 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ DJANGO_LOGGING_FORMATTER=[ndjson|human_readable] # applies to both Django and Celery Workers DJANGO_LOGGING_LEVEL=INFO DJANGO_WORKERS=4 # Defaults to the maximum available based on CPU cores if not set. +# Token lifetime is in minutes +DJANGO_ACCESS_TOKEN_LIFETIME=30 +DJANGO_REFRESH_TOKEN_LIFETIME=1440 +DJANGO_TOKEN_SIGNING_KEY=S3cret DJANGO_CACHE_MAX_AGE=3600 DJANGO_STALE_WHILE_REVALIDATE=60 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c0a3ac553a..d842de06e8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -119,7 +119,7 @@ jobs: - name: Safety if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' run: | - poetry run safety check --ignore 70612 + poetry run safety check --ignore 70612,66963 - name: Vulture if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3a0602ba2..cff3c0d7fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,7 +79,7 @@ repos: - id: safety name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - entry: bash -c 'poetry run safety check --ignore 70612' + entry: bash -c 'poetry run safety check --ignore 70612,66963' language: system - id: vulture diff --git a/README.md b/README.md index 416db1c80c..82fea1deb6 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,12 @@ Fixtures are used to populate the database with initial development data. ```console poetry shell -# For dev tenants -python manage.py loaddata api/fixtures/dev_tenants.json --database admin +# For dev users +python manage.py loaddata api/fixtures/0_dev_users.json --database admin ``` +> The default credentials are `prowler_dev:thisisapassword123` + ## Run tests Note that the tests will fail if you use the same `.env` file as the development environment. diff --git a/poetry.lock b/poetry.lock index 07231ec4fa..66aeeb9947 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1441,6 +1441,30 @@ django-filter = ["django-filter (>=2.4)"] django-polymorphic = ["django-polymorphic (>=3.0)"] openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"] +[[package]] +name = "djangorestframework-simplejwt" +version = "5.3.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, +] + +[package.dependencies] +django = ">=3.2" +djangorestframework = ">=3.12" +pyjwt = ">=1.7.1,<3" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["flake8", "isort", "pep8"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "dnspython" version = "2.6.1" @@ -4770,4 +4794,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "739edc3fdfe9c9ac007914b97a33ae822b550b9d453d3d50677be54fd6caef4a" +content-hash = "bb69d8edb8b1e71769eb678b4104f5e7bd82ba548b94084e97a03c2e1215cda7" diff --git a/pyproject.toml b/pyproject.toml index 14b714f4ff..9fcda05053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ django-filter = "24.2" django-guid = "3.5.0" djangorestframework = "3.15.2" djangorestframework-jsonapi = "7.0.2" +djangorestframework-simplejwt = "^5.3.1" drf-spectacular = "0.27.2" drf-spectacular-jsonapi = "0.4.1" gunicorn = "23.0.0" diff --git a/src/backend/api/base_views.py b/src/backend/api/base_views.py index 893580e058..77397145d4 100644 --- a/src/backend/api/base_views.py +++ b/src/backend/api/base_views.py @@ -1,6 +1,8 @@ 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 @@ -11,6 +13,8 @@ from api.filters import CustomDjangoFilterBackend class BaseViewSet(ModelViewSet): + authentication_classes = [BasicAuthentication] + permission_classes = [permissions.IsAuthenticated] filter_backends = [ filters.QueryParameterValidationFilter, filters.OrderingFilter, diff --git a/src/backend/api/db_utils.py b/src/backend/api/db_utils.py index ca35350308..bcbc79c37e 100644 --- a/src/backend/api/db_utils.py +++ b/src/backend/api/db_utils.py @@ -1,6 +1,7 @@ from contextlib import contextmanager from django.conf import settings +from django.contrib.auth.models import BaseUserManager from django.db import models from psycopg2 import connect as psycopg2_connect from psycopg2.extensions import new_type, register_type, register_adapter, AsIs @@ -37,6 +38,17 @@ def psycopg_connection(database_alias: str): psycopg2_connection.close() +class CustomUserManager(BaseUserManager): + def create_user(self, username, 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.set_password(password) + user.save(using=self._db) + return user + + # Postgres Enums diff --git a/src/backend/api/fixtures/0_dev_users.json b/src/backend/api/fixtures/0_dev_users.json new file mode 100644 index 0000000000..b7bfa2802c --- /dev/null +++ b/src/backend/api/fixtures/0_dev_users.json @@ -0,0 +1,14 @@ +[ + { + "model": "api.user", + "pk": "8b38e2eb-6689-4f1e-a4ba-95b275130200", + "fields": { + "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=", + "last_login": null, + "username": "prowler_dev", + "email": "dev@prowler.com", + "is_active": true, + "date_joined": "2024-09-17T09:04:20.850Z" + } + } +] diff --git a/src/backend/api/fixtures/0_dev_tenants.json b/src/backend/api/fixtures/1_dev_tenants.json similarity index 100% rename from src/backend/api/fixtures/0_dev_tenants.json rename to src/backend/api/fixtures/1_dev_tenants.json diff --git a/src/backend/api/fixtures/1_dev_providers.json b/src/backend/api/fixtures/2_dev_providers.json similarity index 100% rename from src/backend/api/fixtures/1_dev_providers.json rename to src/backend/api/fixtures/2_dev_providers.json diff --git a/src/backend/api/fixtures/2_dev_resources.json b/src/backend/api/fixtures/3_dev_resources.json similarity index 100% rename from src/backend/api/fixtures/2_dev_resources.json rename to src/backend/api/fixtures/3_dev_resources.json diff --git a/src/backend/api/migrations/0001_initial.py b/src/backend/api/migrations/0001_initial.py index 29ae972f68..7973cda658 100644 --- a/src/backend/api/migrations/0001_initial.py +++ b/src/backend/api/migrations/0001_initial.py @@ -1,6 +1,10 @@ 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 import django.db.models.deletion import django.utils.timezone @@ -49,7 +53,10 @@ class Migration(migrations.Migration): # Required for our kind of `RunPython` operations atomic = False - dependencies = [("django_celery_results", "0011_taskresult_periodic_task_name")] + dependencies = [ + ("django_celery_results", "0011_taskresult_periodic_task_name"), + ("auth", "0012_alter_user_first_name_max_length"), + ] operations = [ migrations.RunSQL( @@ -73,6 +80,50 @@ class Migration(migrations.Migration): GRANT SELECT ON django_migrations TO {DB_PROWLER_USER}; """ ), + migrations.CreateModel( + name="User", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "username", + models.CharField( + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("is_active", models.BooleanField(default=True)), + ("date_joined", models.DateTimeField(auto_now_add=True)), + ], + options={ + "db_table": "users", + }, + ), + migrations.AddConstraint( + model_name="user", + constraint=api.rls.BaseSecurityConstraint( + name="statements_on_user", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), # Create and register State type migrations.RunPython( StateEnumMigration.create_enum_type, diff --git a/src/backend/api/models.py b/src/backend/api/models.py index 85c59a4341..859a30a728 100644 --- a/src/backend/api/models.py +++ b/src/backend/api/models.py @@ -1,6 +1,8 @@ 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 @@ -9,10 +11,18 @@ from django.utils.translation import gettext_lazy as _ from django_celery_results.models import TaskResult from uuid6 import uuid7 -from api.db_utils import ProviderEnumField, StateEnumField, ScanTriggerEnumField +from api.db_utils import ( + ProviderEnumField, + StateEnumField, + ScanTriggerEnumField, + CustomUserManager, +) from api.exceptions import ModelValidationError -from api.rls import RowLevelSecurityConstraint -from api.rls import RowLevelSecurityProtectedModel +from api.rls import ( + RowLevelSecurityProtectedModel, + RowLevelSecurityConstraint, + BaseSecurityConstraint, +) class StateChoices(models.TextChoices): @@ -24,6 +34,31 @@ class StateChoices(models.TextChoices): CANCELLED = "cancelled", _("Cancelled") +class User(AbstractBaseUser): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + username = models.CharField( + max_length=150, unique=True, validators=[UnicodeUsernameValidator()] + ) + email = models.EmailField(max_length=254, unique=True) + is_active = models.BooleanField(default=True) + date_joined = models.DateTimeField(auto_now_add=True, editable=False) + + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + + objects = CustomUserManager() + + class Meta: + db_table = "users" + + constraints = [ + BaseSecurityConstraint( + name="statements_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ) + ] + + class Provider(RowLevelSecurityProtectedModel): class ProviderChoices(models.TextChoices): AWS = "aws", _("AWS") diff --git a/src/backend/api/rls.py b/src/backend/api/rls.py index 1d1c6d2a4e..dd8f56353b 100644 --- a/src/backend/api/rls.py +++ b/src/backend/api/rls.py @@ -26,6 +26,8 @@ class Tenant(models.Model): # TODO Add abstract class for non-RLS models class RowLevelSecurityConstraint(models.BaseConstraint): + """Model constraint to enforce row-level security on a tenant based model, in addition to the least privileges.""" + rls_sql_query = """ ALTER TABLE %(table_name)s ENABLE ROW LEVEL SECURITY; ALTER TABLE %(table_name)s FORCE ROW LEVEL SECURITY; @@ -114,6 +116,51 @@ class RowLevelSecurityConstraint(models.BaseConstraint): raise ValidationError(f"{model.__name__} does not have a tenant_id field.") +class BaseSecurityConstraint(models.BaseConstraint): + """Model constraint to grant the least privileges to the API database user.""" + + grant_sql_query = """ + GRANT {statement} ON %(table_name)s TO %(db_user)s; + """ + + drop_sql_query = """ + REVOKE ALL ON TABLE %(table_name) TO %(db_user)s; + """ + + def __init__(self, name: str, statements: list | None = None) -> None: + super().__init__(name=name) + self.statements = statements or ["SELECT"] + + def create_sql(self, model: Any, schema_editor: Any) -> Any: + grant_queries = "" + for statement in self.statements: + grant_queries = ( + f"{grant_queries}{self.grant_sql_query.format(statement=statement)}" + ) + + return Statement( + grant_queries, + table_name=model._meta.db_table, + db_user=DB_USER, + ) + + def remove_sql(self, model: Any, schema_editor: Any) -> Any: + return Statement( + self.drop_sql_query, + table_name=Table(model._meta.db_table, schema_editor.quote_name), + db_user=DB_USER, + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, BaseSecurityConstraint): + return self.name == other.name + return super().__eq__(other) + + def deconstruct(self) -> tuple[str, tuple, dict]: + path, args, kwargs = super().deconstruct() + return path, args, kwargs + + class RowLevelSecurityProtectedModel(models.Model): tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE) diff --git a/src/backend/api/specs/v1.yaml b/src/backend/api/specs/v1.yaml index e2d355e0a7..85c9af2e8e 100644 --- a/src/backend/api/specs/v1.yaml +++ b/src/backend/api/specs/v1.yaml @@ -48,7 +48,7 @@ paths: name: filter[inserted_at] schema: type: string - format: date-time + format: date - in: query name: filter[inserted_at__gte] schema: @@ -81,7 +81,7 @@ paths: name: filter[updated_at] schema: type: string - format: date-time + format: date - in: query name: filter[updated_at__gte] schema: @@ -129,9 +129,7 @@ paths: tags: - Provider security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -159,9 +157,7 @@ paths: $ref: '#/components/schemas/ProviderCreateRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '201': content: @@ -203,9 +199,7 @@ paths: tags: - Provider security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -241,9 +235,7 @@ paths: $ref: '#/components/schemas/PatchedProviderUpdateRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -266,9 +258,7 @@ paths: tags: - Provider security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '202': content: @@ -293,9 +283,7 @@ paths: tags: - Provider security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '202': content: @@ -303,35 +291,49 @@ paths: schema: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' - /api/v1/scans: + /api/v1/resources: get: - operationId: scans_list - description: Retrieve a list of all scans with options for filtering by various - criteria. - summary: List all scans + operationId: resources_list + description: Retrieve a list of all resources with options for filtering by + various criteria. Resources are objects that are discovered by Prowler. They + can be anything from a single host to a whole VPC. + summary: List all resources parameters: - in: query - name: fields[Scan] + name: fields[Resource] schema: type: array items: type: string enum: + - inserted_at + - updated_at + - uid - name - - state - - unique_resource_count - - progress - - scanner_args - - duration + - region + - service + - tags - provider - - started_at - - completed_at - - scheduled_at - url - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time - in: query name: filter[name] schema: @@ -349,6 +351,273 @@ paths: schema: type: string format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[tag] + schema: + type: string + - in: query + name: filter[tag_key] + schema: + type: string + - in: query + name: filter[tag_value] + schema: + type: string + - in: query + name: filter[tags] + schema: + type: string + - in: query + name: filter[type] + schema: + type: string + - in: query + name: filter[type__icontains] + schema: + type: string + - in: query + name: filter[type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[uid__icontains] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - provider_id + - -provider_id + - uid + - -uid + - name + - -name + - region + - -region + - service + - -service + - type + - -type + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Resource + security: + - basicAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceList' + description: '' + /api/v1/resources/{id}: + get: + operationId: resources_retrieve + description: Fetch detailed information about a specific resource by their ID. + A Resource is an object that is discovered by Prowler. It can be anything + from a single host to a whole VPC. + summary: Retrieve data for a resource + parameters: + - in: query + name: fields[Resource] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - uid + - name + - region + - service + - tags + - provider + - url + - type + 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 resource. + required: true + tags: + - Resource + security: + - basicAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ResourceResponse' + description: '' + /api/v1/scans: + get: + operationId: scans_list + description: Retrieve a list of all scans with options for filtering by various + criteria. + summary: List all scans + parameters: + - in: query + name: fields[Scan] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - scanner_args + - duration + - provider + - started_at + - completed_at + - scheduled_at + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[completed_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[provider] + schema: + type: string + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - name: filter[search] required: false in: query @@ -359,7 +628,7 @@ paths: name: filter[started_at] schema: type: string - format: date-time + format: date - in: query name: filter[started_at__gte] schema: @@ -371,7 +640,7 @@ paths: type: string format: date-time - in: query - name: filter[type] + name: filter[trigger] schema: type: string - name: page[number] @@ -399,8 +668,8 @@ paths: - -provider_id - name - -name - - type - - -type + - trigger + - -trigger - attempted_at - -attempted_at - scheduled_at @@ -413,9 +682,7 @@ paths: tags: - Scan security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -447,9 +714,7 @@ paths: $ref: '#/components/schemas/ScanCreateRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '201': content: @@ -471,6 +736,7 @@ paths: type: string enum: - name + - trigger - state - unique_resource_count - progress @@ -481,7 +747,6 @@ paths: - completed_at - scheduled_at - url - - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -495,9 +760,7 @@ paths: tags: - Scan security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -533,9 +796,7 @@ paths: $ref: '#/components/schemas/PatchedScanUpdateRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -562,6 +823,7 @@ paths: - name - state - result + - task_args - metadata description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -617,9 +879,7 @@ paths: tags: - Task security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -645,6 +905,7 @@ paths: - name - state - result + - task_args - metadata description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -659,9 +920,7 @@ paths: tags: - Task security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -671,9 +930,8 @@ paths: description: '' delete: operationId: tasks_destroy - description: Try to revoke a task using its ID. If the task is being already - executed, its result will be ignored but it will finish. To prevent this, - use the `force` query parameter under your own risk. + description: Try to revoke a task using its ID. Only tasks that are not yet + in progress can be revoked. summary: Revoke a task parameters: - in: path @@ -686,9 +944,7 @@ paths: tags: - Task security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '202': content: @@ -720,7 +976,12 @@ paths: name: filter[inserted_at] schema: type: string - format: date-time + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date - in: query name: filter[inserted_at__gte] schema: @@ -749,7 +1010,7 @@ paths: name: filter[updated_at] schema: type: string - format: date-time + format: date - in: query name: filter[updated_at__gte] schema: @@ -791,9 +1052,7 @@ paths: tags: - Tenant security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -821,9 +1080,7 @@ paths: $ref: '#/components/schemas/TenantRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '201': content: @@ -860,9 +1117,7 @@ paths: tags: - Tenant security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -898,9 +1153,7 @@ paths: $ref: '#/components/schemas/PatchedTenantRequest' required: true security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '200': content: @@ -923,12 +1176,126 @@ paths: tags: - Tenant security: - - cookieAuth: [] - basicAuth: [] - - {} responses: '204': description: No response body + /api/v1/users: + post: + operationId: users_create + description: Create a new user account by providing the necessary registration + details. + summary: Register a new user + tags: + - User + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/UserCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserCreateRequest' + required: true + security: + - basicAuth: [] + - {} + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/UserCreateResponse' + description: '' + /api/v1/users/{id}: + patch: + operationId: users_partial_update + description: Partially update the authenticated user's information. + summary: Update the current user's information + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this user. + required: true + tags: + - User + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedUserUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUserUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUserUpdateRequest' + required: true + security: + - basicAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/UserUpdateResponse' + description: '' + delete: + operationId: users_destroy + description: Remove the authenticated user's account from the system. + summary: Delete the current user's account + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this user. + required: true + tags: + - User + security: + - basicAuth: [] + responses: + '204': + description: No response body + /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: + - username + - password + - 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: '' components: schemas: DelayedTask: @@ -969,6 +1336,15 @@ components: $ref: '#/components/schemas/Provider' required: - data + PaginatedResourceList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Resource' + required: + - data PaginatedScanList: type: object properties: @@ -1094,6 +1470,42 @@ components: - name required: - data + PatchedUserUpdateRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - User + id: + type: string + format: uuid + attributes: + type: object + properties: + password: + type: string + writeOnly: true + minLength: 1 + email: + type: string + format: email + minLength: 1 + maxLength: 254 + required: + - email + required: + - data Provider: type: object required: @@ -1253,6 +1665,100 @@ components: $ref: '#/components/schemas/Provider' required: - data + Resource: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ResourceTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + uid: + type: string + title: Unique identifier for the resource, set by the provider + name: + type: string + title: Name of the resource, as set in the provider + region: + type: string + title: Location of the resource, as set by the provider + service: + type: string + title: Service of the resource, as set by the provider + tags: + type: object + description: Tags associated with the resource + example: + env: prod + owner: johndoe + readOnly: true + type: + type: string + readOnly: true + required: + - uid + - name + - region + - service + relationships: + type: object + properties: + provider: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - Provider + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + required: + - provider + ResourceResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Resource' + required: + - data + ResourceTypeEnum: + type: string + enum: + - Resource Scan: type: object required: @@ -1277,6 +1783,15 @@ components: nullable: true maxLength: 100 minLength: 3 + trigger: + enum: + - scheduled + - manual + type: string + description: |- + * `scheduled` - Scheduled + * `manual` - Manual + readOnly: true state: enum: - available @@ -1320,15 +1835,6 @@ components: type: string format: date-time nullable: true - type: - enum: - - scheduled - - manual - type: string - description: |- - * `scheduled` - Scheduled - * `manual` - Manual - readOnly: true relationships: type: object properties: @@ -1527,6 +2033,8 @@ components: readOnly: true result: readOnly: true + task_args: + readOnly: true metadata: readOnly: true TaskResponse: @@ -1622,15 +2130,172 @@ components: type: string enum: - Provider + TypeAddEnum: + type: string + enum: + - User TypeB52Enum: type: string enum: - Task + User: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TypeAddEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: + type: string + format: uuid + attributes: + type: object + properties: + username: + type: string + pattern: ^[\w.@+-]+$ + maxLength: 150 + password: + type: string + writeOnly: true + maxLength: 128 + email: + type: string + format: email + maxLength: 254 + date_joined: + type: string + format: date-time + readOnly: true + required: + - username + - password + - email + UserCreate: + type: object + required: + - type + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TypeAddEnum' + 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: + username: + type: string + pattern: ^[\w.@+-]+$ + maxLength: 150 + password: + type: string + writeOnly: true + email: + type: string + format: email + maxLength: 254 + required: + - username + - password + - email + UserCreateRequest: + 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: + - User + attributes: + type: object + properties: + username: + type: string + minLength: 1 + pattern: ^[\w.@+-]+$ + maxLength: 150 + password: + type: string + writeOnly: true + minLength: 1 + email: + type: string + format: email + minLength: 1 + maxLength: 254 + required: + - username + - password + - email + required: + - data + UserCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/UserCreate' + required: + - data + UserResponse: + type: object + properties: + data: + $ref: '#/components/schemas/User' + required: + - data + UserUpdate: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TypeAddEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: + type: string + format: uuid + attributes: + type: object + properties: + password: + type: string + writeOnly: true + email: + type: string + format: email + maxLength: 254 + required: + - email + UserUpdateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/UserUpdate' + required: + - data securitySchemes: basicAuth: type: http scheme: basic - cookieAuth: - type: apiKey - in: cookie - name: sessionid diff --git a/src/backend/api/tests/integration/test_authentication.py b/src/backend/api/tests/integration/test_authentication.py new file mode 100644 index 0000000000..29aa4b260b --- /dev/null +++ b/src/backend/api/tests/integration/test_authentication.py @@ -0,0 +1,44 @@ +import base64 + +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_basic_authentication(providers_fixture, tenant_header): + client = APIClient() + + test_user = "test_user" + 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) + assert no_auth_response.status_code == 401 + + # Check that we can create a new user without any kind of authentication + user_creation_response = client.post( + reverse("user-list"), + data={ + "data": { + "type": "User", + "attributes": { + "username": test_user, + "password": test_password, + "email": "thisisnotimportant@prowler.com", + }, + } + }, + format="vnd.api+json", + ) + assert user_creation_response.status_code == 201 + + # Check that using our new user's credentials we can authenticate and get the providers + auth_response = client.get( + reverse("provider-list"), + headers=tenant_header, + HTTP_AUTHORIZATION=f"Basic {credentials}", + ) + assert auth_response.status_code == 200 + assert len(auth_response.json()["data"]) == len(providers_fixture) diff --git a/src/backend/api/tests/integration/test_tenants.py b/src/backend/api/tests/integration/test_tenants.py index cf3afcf8e6..033e32349e 100644 --- a/src/backend/api/tests/integration/test_tenants.py +++ b/src/backend/api/tests/integration/test_tenants.py @@ -1,13 +1,12 @@ import pytest from django.urls import reverse -from rest_framework.test import APIClient @pytest.mark.django_db def test_check_resources_between_different_tenants( - enforce_test_user_db_connection, tenants_fixture + enforce_test_user_db_connection, authenticated_api_client, tenants_fixture ): - client = APIClient() + client = authenticated_api_client tenant1 = str(tenants_fixture[0].id) tenant2 = str(tenants_fixture[1].id) diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index debc28ba6e..e8ad7733d8 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -1,17 +1,257 @@ +from datetime import datetime from unittest.mock import Mock, patch import pytest from django.urls import reverse from rest_framework import status -from datetime import datetime -from api.models import Provider, Scan + +from api.models import User, Provider, Scan from api.rls import Tenant from conftest import API_JSON_CONTENT_TYPE, NO_TENANT_HTTP_STATUS - TODAY = str(datetime.today().date()) +@pytest.mark.django_db +class TestUserViewSet: + def test_users_list_not_allowed(self, authenticated_client): + response = authenticated_client.get(reverse("user-list")) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_users_retrieve_not_allowed(self, authenticated_client, create_test_user): + response = authenticated_client.get( + reverse("user-detail", kwargs={"pk": create_test_user.id}) + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + 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 + ) + + def test_users_create(self, client): + valid_user_payload = { + "username": "newuser", + "password": "newpassword123", + "email": "newuser@example.com", + } + response = client.post( + 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" + + def test_users_invalid_create(self, client): + invalid_user_payload = { + "username": "theusernameisfine", + "password": "thepasswordisfine123", + "email": "invalidemail", + } + response = client.post( + reverse("user-list"), data=invalid_user_payload, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/email" + ) + + @pytest.mark.parametrize( + "password", + [ + # Fails MinimumLengthValidator (too short) + "short", + "1234567", + # Fails CommonPasswordValidator (common passwords) + "password", + "12345678", + "qwerty", + "abc123", + # 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 + ("querty12" * 9) + "a", # Too long, 73 characters + ], + ) + def test_users_create_invalid_passwords(self, authenticated_client, password): + invalid_user_payload = { + "username": "thisisfine", + "password": password, + "email": "thisisafineemail@prowler.com", + } + response = authenticated_client.post( + reverse("user-list"), data=invalid_user_payload, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/password" + ) + + def test_users_partial_update(self, authenticated_client, create_test_user): + new_email = "updated@example.com" + payload = { + "data": { + "type": "User", + "id": str(create_test_user.id), + "attributes": {"email": new_email}, + }, + } + response = authenticated_client.patch( + reverse("user-detail", kwargs={"pk": create_test_user.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + create_test_user.refresh_from_db() + assert create_test_user.email == new_email + + def test_users_partial_update_invalid_content_type( + self, authenticated_client, create_test_user + ): + response = authenticated_client.patch( + reverse("user-detail", kwargs={"pk": create_test_user.id}), data={} + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + def test_users_partial_update_invalid_content( + self, authenticated_client, create_test_user + ): + payload = {"email": "newemail@example.com"} + response = authenticated_client.patch( + reverse("user-detail", kwargs={"pk": create_test_user.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_users_partial_update_invalid_user( + self, authenticated_client, create_test_user + ): + another_user = User.objects.create_user( + username="otheruser", password="otherpassword", email="other@example.com" + ) + new_email = "new@example.com" + payload = { + "data": { + "type": "User", + "id": str(another_user.id), + "attributes": {"email": new_email}, + }, + } + response = authenticated_client.patch( + reverse("user-detail", kwargs={"pk": another_user.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + another_user.refresh_from_db() + assert another_user.email != new_email + + @pytest.mark.parametrize( + "password", + [ + # Fails MinimumLengthValidator (too short) + "short", + "1234567", + # Fails CommonPasswordValidator (common passwords) + "password", + "12345678", + "qwerty", + "abc123", + # Fails NumericPasswordValidator (entirely numeric) + "12345678", + "00000000", + # Fails UserAttributeSimilarityValidator (too similar to username or email) + "testing123", + "thisistesting", + "testing@gmail.com", + ], + ) + def test_users_partial_update_invalid_password( + self, authenticated_client, create_test_user, password + ): + payload = { + "data": { + "type": "User", + "id": str(create_test_user.id), + "attributes": {"password": password}, + }, + } + + response = authenticated_client.patch( + reverse("user-detail", kwargs={"pk": str(create_test_user.id)}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/password" + ) + + def test_users_destroy(self, authenticated_client, create_test_user): + response = authenticated_client.delete( + reverse("user-detail", kwargs={"pk": create_test_user.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not User.objects.filter(id=create_test_user.id).exists() + + 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" + ) + response = authenticated_client.delete( + reverse("user-detail", kwargs={"pk": another_user.id}) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert User.objects.filter(id=another_user.id).exists() + + @pytest.mark.parametrize( + "attribute_key, attribute_value, error_field", + [ + ("username", "", "username"), + ("password", "", "password"), + ("email", "invalidemail", "email"), + ], + ) + def test_users_create_invalid_fields( + self, client, attribute_key, attribute_value, error_field + ): + invalid_payload = { + "username": "testuser", + "password": "testpassword", + "email": "test@example.com", + } + invalid_payload[attribute_key] = attribute_value + response = client.post( + reverse("user-list"), data=invalid_payload, format="json" + ) + 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: @pytest.fixture @@ -30,31 +270,35 @@ class TestTenantViewSet: "updated_at": "2023-01-06", } - def test_tenants_list(self, client, tenants_fixture): - response = client.get(reverse("tenant-list")) + def test_tenants_list(self, authenticated_client, tenants_fixture): + response = authenticated_client.get(reverse("tenant-list")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(tenants_fixture) - def test_tenants_retrieve(self, client, tenants_fixture): + def test_tenants_retrieve(self, authenticated_client, tenants_fixture): tenant1, _ = tenants_fixture - response = client.get(reverse("tenant-detail", kwargs={"pk": tenant1.id})) + response = authenticated_client.get( + reverse("tenant-detail", kwargs={"pk": tenant1.id}) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["name"] == tenant1.name - def test_tenants_invalid_retrieve(self, client): - response = client.get(reverse("tenant-detail", kwargs={"pk": "random_id"})) + def test_tenants_invalid_retrieve(self, authenticated_client): + response = authenticated_client.get( + reverse("tenant-detail", kwargs={"pk": "random_id"}) + ) assert response.status_code == status.HTTP_404_NOT_FOUND - def test_tenants_create(self, client, valid_tenant_payload): - response = client.post( + def test_tenants_create(self, authenticated_client, valid_tenant_payload): + response = authenticated_client.post( 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"] - def test_tenants_invalid_create(self, client, invalid_tenant_payload): - response = client.post( + def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload): + response = authenticated_client.post( reverse("tenant-list"), data=invalid_tenant_payload, format="json", @@ -62,7 +306,7 @@ class TestTenantViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_tenants_partial_update(self, client, tenants_fixture): + def test_tenants_partial_update(self, authenticated_client, tenants_fixture): tenant1, _ = tenants_fixture new_name = "This is the new name" payload = { @@ -72,7 +316,7 @@ class TestTenantViewSet: "attributes": {"name": new_name}, }, } - response = client.patch( + response = authenticated_client.patch( reverse("tenant-detail", kwargs={"pk": tenant1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -81,51 +325,63 @@ class TestTenantViewSet: tenant1.refresh_from_db() assert tenant1.name == new_name - def test_tenants_partial_update_invalid_content_type(self, client, tenants_fixture): + def test_tenants_partial_update_invalid_content_type( + self, authenticated_client, tenants_fixture + ): tenant1, _ = tenants_fixture - response = client.patch( + response = authenticated_client.patch( reverse("tenant-detail", kwargs={"pk": tenant1.id}), data={} ) assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE - def test_tenants_partial_update_invalid_content(self, client, tenants_fixture): + def test_tenants_partial_update_invalid_content( + self, authenticated_client, tenants_fixture + ): tenant1, _ = tenants_fixture new_name = "This is the new name" payload = {"name": new_name} - response = client.patch( + response = authenticated_client.patch( reverse("tenant-detail", kwargs={"pk": tenant1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_tenants_delete(self, client, tenants_fixture): + def test_tenants_delete(self, authenticated_client, tenants_fixture): tenant1, _ = tenants_fixture - response = client.delete(reverse("tenant-detail", kwargs={"pk": tenant1.id})) + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant1.id}) + ) assert response.status_code == status.HTTP_204_NO_CONTENT assert Tenant.objects.count() == 1 - def test_tenants_delete_invalid(self, client): - response = client.delete(reverse("tenant-detail", kwargs={"pk": "random_id"})) + def test_tenants_delete_invalid(self, authenticated_client): + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": "random_id"}) + ) # To change if we implement RBAC # (user might not have permissions to see if the tenant exists or not -> 200 empty) assert response.status_code == status.HTTP_404_NOT_FOUND - def test_tenants_list_filter_search(self, client, tenants_fixture): + def test_tenants_list_filter_search(self, authenticated_client, tenants_fixture): """Search is applied to tenants_fixture name.""" tenant1, _ = tenants_fixture - response = client.get(reverse("tenant-list"), {"filter[search]": tenant1.name}) + response = authenticated_client.get( + reverse("tenant-list"), {"filter[search]": tenant1.name} + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 1 assert response.json()["data"][0]["attributes"]["name"] == tenant1.name - def test_tenants_list_query_param_name(self, client, tenants_fixture): + def test_tenants_list_query_param_name(self, authenticated_client, tenants_fixture): tenant1, _ = tenants_fixture - response = client.get(reverse("tenant-list"), {"name": tenant1.name}) + response = authenticated_client.get( + reverse("tenant-list"), {"name": tenant1.name} + ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_tenants_list_invalid_query_param(self, client): - response = client.get(reverse("tenant-list"), {"random": "value"}) + def test_tenants_list_invalid_query_param(self, authenticated_client): + response = authenticated_client.get(reverse("tenant-list"), {"random": "value"}) assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( @@ -144,14 +400,14 @@ class TestTenantViewSet: ) def test_tenants_filters( self, - client, + authenticated_client, tenants_fixture, tenant_header, filter_name, filter_value, expected_count, ): - response = client.get( + response = authenticated_client.get( reverse("tenant-list"), {f"filter[{filter_name}]": filter_value}, headers=tenant_header, @@ -160,24 +416,28 @@ class TestTenantViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count - def test_tenants_list_filter_invalid(self, client): - response = client.get(reverse("tenant-list"), {"filter[invalid]": "whatever"}) + def test_tenants_list_filter_invalid(self, authenticated_client): + response = authenticated_client.get( + reverse("tenant-list"), {"filter[invalid]": "whatever"} + ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_tenants_list_page_size(self, client, tenants_fixture): + def test_tenants_list_page_size(self, authenticated_client, tenants_fixture): page_size = 1 - response = client.get(reverse("tenant-list"), {"page[size]": page_size}) + response = authenticated_client.get( + reverse("tenant-list"), {"page[size]": page_size} + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == page_size assert response.json()["meta"]["pagination"]["page"] == 1 assert response.json()["meta"]["pagination"]["pages"] == len(tenants_fixture) - def test_tenants_list_page_number(self, client, tenants_fixture): + def test_tenants_list_page_number(self, authenticated_client, tenants_fixture): page_size = 1 page_number = 2 - response = client.get( + response = authenticated_client.get( reverse("tenant-list"), {"page[size]": page_size, "page[number]": page_number}, ) @@ -186,9 +446,9 @@ class TestTenantViewSet: assert response.json()["meta"]["pagination"]["page"] == page_number assert response.json()["meta"]["pagination"]["pages"] == len(tenants_fixture) - def test_tenants_list_sort_name(self, client, tenants_fixture): + def test_tenants_list_sort_name(self, authenticated_client, tenants_fixture): _, tenant2 = tenants_fixture - response = client.get(reverse("tenant-list"), {"sort": "-name"}) + response = authenticated_client.get(reverse("tenant-list"), {"sort": "-name"}) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 assert response.json()["data"][0]["attributes"]["name"] == tenant2.name @@ -196,18 +456,24 @@ class TestTenantViewSet: @pytest.mark.django_db class TestProviderViewSet: - def test_providers_rls(self, client): - response = client.get(reverse("provider-list")) + def test_providers_rls(self, authenticated_client): + response = authenticated_client.get(reverse("provider-list")) assert response.status_code == NO_TENANT_HTTP_STATUS - def test_providers_list(self, client, providers_fixture, tenant_header): - response = client.get(reverse("provider-list"), headers=tenant_header) + 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, client, providers_fixture, tenant_header): + def test_providers_retrieve( + self, authenticated_client, providers_fixture, tenant_header + ): provider1, *_ = providers_fixture - response = client.get( + response = authenticated_client.get( reverse("provider-detail", kwargs={"pk": provider1.id}), headers=tenant_header, ) @@ -216,8 +482,8 @@ class TestProviderViewSet: assert response.json()["data"]["attributes"]["uid"] == provider1.uid assert response.json()["data"]["attributes"]["alias"] == provider1.alias - def test_providers_invalid_retrieve(self, client, tenant_header): - response = client.get( + def test_providers_invalid_retrieve(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("provider-detail", kwargs={"pk": "random_id"}), headers=tenant_header, ) @@ -242,8 +508,10 @@ class TestProviderViewSet: ] ), ) - def test_providers_create_valid(self, client, tenant_header, provider_json_payload): - response = client.post( + def test_providers_create_valid( + self, authenticated_client, tenant_header, provider_json_payload + ): + response = authenticated_client.post( reverse("provider-list"), data=provider_json_payload, format="json", @@ -314,9 +582,14 @@ class TestProviderViewSet: ), ) def test_providers_invalid_create( - self, client, tenant_header, provider_json_payload, error_code, error_pointer + self, + authenticated_client, + tenant_header, + provider_json_payload, + error_code, + error_pointer, ): - response = client.post( + response = authenticated_client.post( reverse("provider-list"), data=provider_json_payload, format="json", @@ -329,7 +602,9 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) - def test_providers_partial_update(self, client, providers_fixture, tenant_header): + def test_providers_partial_update( + self, authenticated_client, providers_fixture, tenant_header + ): provider1, *_ = providers_fixture new_alias = "This is the new name" payload = { @@ -339,7 +614,7 @@ class TestProviderViewSet: "attributes": {"alias": new_alias}, }, } - response = client.patch( + response = authenticated_client.patch( reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -350,10 +625,10 @@ class TestProviderViewSet: assert provider1.alias == new_alias def test_providers_partial_update_invalid_content_type( - self, client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture, tenant_header ): provider1, *_ = providers_fixture - response = client.patch( + response = authenticated_client.patch( reverse("provider-detail", kwargs={"pk": provider1.id}), data={}, headers=tenant_header, @@ -361,12 +636,12 @@ class TestProviderViewSet: assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE def test_providers_partial_update_invalid_content( - self, client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture, tenant_header ): provider1, *_ = providers_fixture new_name = "This is the new name" payload = {"alias": new_name} - response = client.patch( + response = authenticated_client.patch( reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -382,7 +657,12 @@ class TestProviderViewSet: ], ) def test_providers_partial_update_invalid_fields( - self, client, providers_fixture, tenant_header, attribute_key, attribute_value + self, + authenticated_client, + providers_fixture, + tenant_header, + attribute_key, + attribute_value, ): provider1, *_ = providers_fixture payload = { @@ -392,7 +672,7 @@ class TestProviderViewSet: "attributes": {attribute_key: attribute_value}, }, } - response = client.patch( + response = authenticated_client.patch( reverse("provider-detail", kwargs={"pk": provider1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -402,14 +682,14 @@ class TestProviderViewSet: @patch("api.v1.views.delete_provider_task.delay") def test_providers_delete( - self, mock_delete_task, client, providers_fixture, tenant_header + self, mock_delete_task, authenticated_client, providers_fixture, tenant_header ): task_mock = Mock() task_mock.id = "12345" mock_delete_task.return_value = task_mock provider1, *_ = providers_fixture - response = client.delete( + response = authenticated_client.delete( reverse("provider-detail", kwargs={"pk": provider1.id}), headers=tenant_header, ) @@ -420,8 +700,8 @@ class TestProviderViewSet: assert "Content-Location" in response.headers assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}" - def test_providers_delete_invalid(self, client, tenant_header): - response = client.delete( + def test_providers_delete_invalid(self, authenticated_client, tenant_header): + response = authenticated_client.delete( reverse("provider-detail", kwargs={"pk": "random_id"}), headers=tenant_header, ) @@ -429,7 +709,11 @@ class TestProviderViewSet: @patch("api.v1.views.check_provider_connection_task.delay") def test_providers_connection( - self, mock_provider_connection, client, providers_fixture, tenant_header + self, + mock_provider_connection, + authenticated_client, + providers_fixture, + tenant_header, ): task_mock = Mock() task_mock.id = "12345" @@ -440,7 +724,7 @@ class TestProviderViewSet: assert provider1.connected is None assert provider1.connection_last_checked_at is None - response = client.post( + response = authenticated_client.post( reverse("provider-connection", kwargs={"pk": provider1.id}), headers=tenant_header, ) @@ -452,9 +736,9 @@ class TestProviderViewSet: assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}" def test_providers_connection_invalid_provider( - self, client, providers_fixture, tenant_header + self, authenticated_client, providers_fixture, tenant_header ): - response = client.post( + response = authenticated_client.post( reverse("provider-connection", kwargs={"pk": "random_id"}), headers=tenant_header, ) @@ -480,14 +764,14 @@ class TestProviderViewSet: ) def test_providers_filters( self, - client, + authenticated_client, providers_fixture, tenant_header, filter_name, filter_value, expected_count, ): - response = client.get( + response = authenticated_client.get( reverse("provider-list"), {f"filter[{filter_name}]": filter_value}, headers=tenant_header, @@ -505,8 +789,10 @@ class TestProviderViewSet: ] ), ) - def test_providers_filters_invalid(self, client, tenant_header, filter_name): - response = client.get( + def test_providers_filters_invalid( + self, authenticated_client, tenant_header, filter_name + ): + response = authenticated_client.get( reverse("provider-list"), {f"filter[{filter_name}]": "whatever"}, headers=tenant_header, @@ -526,14 +812,14 @@ class TestProviderViewSet: ] ), ) - def test_providers_sort(self, client, tenant_header, sort_field): - response = client.get( + def test_providers_sort(self, authenticated_client, tenant_header, sort_field): + response = authenticated_client.get( reverse("provider-list"), {"sort": sort_field}, headers=tenant_header ) assert response.status_code == status.HTTP_200_OK - def test_providers_sort_invalid(self, client, tenant_header): - response = client.get( + def test_providers_sort_invalid(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("provider-list"), {"sort": "invalid"}, headers=tenant_header ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -541,14 +827,14 @@ class TestProviderViewSet: @pytest.mark.django_db class TestScanViewSet: - def test_scans_list(self, client, scans_fixture, tenant_header): - response = client.get(reverse("scan-list"), headers=tenant_header) + def test_scans_list(self, authenticated_client, scans_fixture, tenant_header): + response = authenticated_client.get(reverse("scan-list"), headers=tenant_header) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(scans_fixture) - def test_scans_retrieve(self, client, scans_fixture, tenant_header): + def test_scans_retrieve(self, authenticated_client, scans_fixture, tenant_header): scan1, *_ = scans_fixture - response = client.get( + response = authenticated_client.get( reverse("scan-detail", kwargs={"pk": scan1.id}), headers=tenant_header, ) @@ -558,8 +844,8 @@ class TestScanViewSet: "id" ] == str(scan1.provider.id) - def test_scans_invalid_retrieve(self, client, tenant_header): - response = client.get( + def test_scans_invalid_retrieve(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("scan-detail", kwargs={"pk": "random_id"}), headers=tenant_header, ) @@ -609,7 +895,7 @@ class TestScanViewSet: ) def test_scans_create_valid( self, - client, + authenticated_client, tenant_header, scan_json_payload, expected_scanner_args, @@ -623,7 +909,7 @@ class TestScanViewSet: provider5.id ) - response = client.post( + response = authenticated_client.post( reverse("scan-list"), data=scan_json_payload, content_type=API_JSON_CONTENT_TYPE, @@ -662,13 +948,18 @@ class TestScanViewSet: ], ) def test_scans_invalid_create( - self, client, tenant_header, scan_json_payload, providers_fixture, error_code + self, + authenticated_client, + tenant_header, + scan_json_payload, + providers_fixture, + error_code, ): provider1, *_ = providers_fixture scan_json_payload["data"]["relationships"]["provider"]["data"]["id"] = str( provider1.id ) - response = client.post( + response = authenticated_client.post( reverse("scan-list"), data=scan_json_payload, content_type=API_JSON_CONTENT_TYPE, @@ -680,7 +971,9 @@ class TestScanViewSet: response.json()["errors"][0]["source"]["pointer"] == "/data/attributes/name" ) - def test_scans_partial_update(self, client, scans_fixture, tenant_header): + def test_scans_partial_update( + self, authenticated_client, scans_fixture, tenant_header + ): scan1, *_ = scans_fixture new_name = "Updated Scan Name" payload = { @@ -690,7 +983,7 @@ class TestScanViewSet: "attributes": {"name": new_name}, }, } - response = client.patch( + response = authenticated_client.patch( reverse("scan-detail", kwargs={"pk": scan1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -701,10 +994,10 @@ class TestScanViewSet: assert scan1.name == new_name def test_scans_partial_update_invalid_content_type( - self, client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture, tenant_header ): scan1, *_ = scans_fixture - response = client.patch( + response = authenticated_client.patch( reverse("scan-detail", kwargs={"pk": scan1.id}), data={}, headers=tenant_header, @@ -712,12 +1005,12 @@ class TestScanViewSet: assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE def test_scans_partial_update_invalid_content( - self, client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture, tenant_header ): scan1, *_ = scans_fixture new_name = "Updated Scan Name" payload = {"name": new_name} - response = client.patch( + response = authenticated_client.patch( reverse("scan-detail", kwargs={"pk": scan1.id}), data=payload, content_type=API_JSON_CONTENT_TYPE, @@ -748,14 +1041,14 @@ class TestScanViewSet: ) def test_scans_filters( self, - client, + authenticated_client, scans_fixture, tenant_header, filter_name, filter_value, expected_count, ): - response = client.get( + response = authenticated_client.get( reverse("scan-list"), {f"filter[{filter_name}]": filter_value}, headers=tenant_header, @@ -771,8 +1064,10 @@ class TestScanViewSet: "invalid", ], ) - def test_scans_filters_invalid(self, client, tenant_header, filter_name): - response = client.get( + def test_scans_filters_invalid( + self, authenticated_client, tenant_header, filter_name + ): + response = authenticated_client.get( reverse("scan-list"), {f"filter[{filter_name}]": "invalid_value"}, headers=tenant_header, @@ -780,9 +1075,9 @@ class TestScanViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST def test_scan_filter_by_provider_id_exact( - self, client, scans_fixture, tenant_header + self, authenticated_client, scans_fixture, tenant_header ): - response = client.get( + response = authenticated_client.get( reverse("scan-list"), {"filter[provider]": scans_fixture[0].provider.id}, headers=tenant_header, @@ -790,8 +1085,10 @@ class TestScanViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 - def test_scan_filter_by_provider_id_in(self, client, scans_fixture, tenant_header): - response = client.get( + def test_scan_filter_by_provider_id_in( + self, authenticated_client, scans_fixture, tenant_header + ): + response = authenticated_client.get( reverse("scan-list"), { "filter[provider.in]": [ @@ -813,14 +1110,14 @@ class TestScanViewSet: "updated_at", ], ) - def test_scans_sort(self, client, tenant_header, sort_field): - response = client.get( + def test_scans_sort(self, authenticated_client, tenant_header, sort_field): + response = authenticated_client.get( reverse("scan-list"), {"sort": sort_field}, headers=tenant_header ) assert response.status_code == status.HTTP_200_OK - def test_scans_sort_invalid(self, client, tenant_header): - response = client.get( + def test_scans_sort_invalid(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("scan-list"), {"sort": "invalid"}, headers=tenant_header ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -828,14 +1125,14 @@ class TestScanViewSet: @pytest.mark.django_db class TestTaskViewSet: - def test_tasks_list(self, client, tasks_fixture, tenant_header): - response = client.get(reverse("task-list"), headers=tenant_header) + def test_tasks_list(self, authenticated_client, tasks_fixture, tenant_header): + response = authenticated_client.get(reverse("task-list"), headers=tenant_header) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(tasks_fixture) - def test_tasks_retrieve(self, client, tasks_fixture, tenant_header): + def test_tasks_retrieve(self, authenticated_client, tasks_fixture, tenant_header): task1, *_ = tasks_fixture - response = client.get( + response = authenticated_client.get( reverse("task-detail", kwargs={"pk": task1.id}), headers=tenant_header, ) @@ -845,33 +1142,35 @@ class TestTaskViewSet: == task1.task_runner_task.task_name ) - def test_tasks_invalid_retrieve(self, client, tenant_header): - response = client.get( + def test_tasks_invalid_retrieve(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("task-detail", kwargs={"pk": "invalid_id"}), headers=tenant_header ) 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, client, tasks_fixture, tenant_header + self, mock_async_result, authenticated_client, tasks_fixture, tenant_header ): _, task2 = tasks_fixture - response = client.delete( + response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task2.id}), headers=tenant_header ) 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, client, tenant_header): - response = client.delete( + def test_tasks_invalid_revoke(self, authenticated_client, tenant_header): + response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": "invalid_id"}), headers=tenant_header ) assert response.status_code == status.HTTP_404_NOT_FOUND - def test_tasks_revoke_invalid_status(self, client, tasks_fixture, tenant_header): + def test_tasks_revoke_invalid_status( + self, authenticated_client, tasks_fixture, tenant_header + ): task1, _ = tasks_fixture - response = client.delete( + response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task1.id}), headers=tenant_header ) # Task status is SUCCESS @@ -880,13 +1179,19 @@ class TestTaskViewSet: @pytest.mark.django_db class TestResourceViewSet: - def test_resources_list_none(self, client, tenant_header): - response = client.get(reverse("resource-list"), headers=tenant_header) + def test_resources_list_none(self, authenticated_client, tenant_header): + response = authenticated_client.get( + reverse("resource-list"), headers=tenant_header + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 - def test_resources_list(self, client, resources_fixture, tenant_header): - response = client.get(reverse("resource-list"), headers=tenant_header) + def test_resources_list( + self, authenticated_client, resources_fixture, tenant_header + ): + response = authenticated_client.get( + reverse("resource-list"), headers=tenant_header + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(resources_fixture) assert ( @@ -939,14 +1244,14 @@ class TestResourceViewSet: ) def test_resource_filters( self, - client, + authenticated_client, resources_fixture, tenant_header, filter_name, filter_value, expected_count, ): - response = client.get( + response = authenticated_client.get( reverse("resource-list"), {f"filter[{filter_name}]": filter_value}, headers=tenant_header, @@ -956,9 +1261,9 @@ class TestResourceViewSet: assert len(response.json()["data"]) == expected_count def test_resource_filter_by_provider_id_in( - self, client, resources_fixture, tenant_header + self, authenticated_client, resources_fixture, tenant_header ): - response = client.get( + response = authenticated_client.get( reverse("resource-list"), { "filter[provider.in]": [ @@ -980,8 +1285,10 @@ class TestResourceViewSet: ] ), ) - def test_resources_filters_invalid(self, client, tenant_header, filter_name): - response = client.get( + def test_resources_filters_invalid( + self, authenticated_client, tenant_header, filter_name + ): + response = authenticated_client.get( reverse("resource-list"), {f"filter[{filter_name}]": "whatever"}, headers=tenant_header, @@ -1001,14 +1308,14 @@ class TestResourceViewSet: "updated_at", ], ) - def test_resources_sort(self, client, tenant_header, sort_field): - response = client.get( + def test_resources_sort(self, authenticated_client, tenant_header, sort_field): + response = authenticated_client.get( reverse("resource-list"), {"sort": sort_field}, headers=tenant_header ) assert response.status_code == status.HTTP_200_OK - def test_resources_sort_invalid(self, client, tenant_header): - response = client.get( + def test_resources_sort_invalid(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("resource-list"), {"sort": "invalid"}, headers=tenant_header ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -1018,9 +1325,11 @@ class TestResourceViewSet: response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid" ) - def test_resources_retrieve(self, client, resources_fixture, tenant_header): + def test_resources_retrieve( + self, authenticated_client, resources_fixture, tenant_header + ): resource_1, *_ = resources_fixture - response = client.get( + response = authenticated_client.get( reverse("resource-detail", kwargs={"pk": resource_1.id}), headers=tenant_header, ) @@ -1032,8 +1341,8 @@ 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, client, tenant_header): - response = client.get( + def test_resources_invalid_retrieve(self, authenticated_client, tenant_header): + response = authenticated_client.get( reverse("resource-detail", kwargs={"pk": "random_id"}), headers=tenant_header, ) diff --git a/src/backend/api/v1/serializers.py b/src/backend/api/v1/serializers.py index e47e0cb0e1..bb75e080a1 100644 --- a/src/backend/api/v1/serializers.py +++ b/src/backend/api/v1/serializers.py @@ -1,10 +1,11 @@ import json +from django.contrib.auth.password_validation import validate_password from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers from rest_framework_json_api.serializers import ValidationError -from api.models import StateChoices, Provider, Scan, Task, Resource, ResourceTag +from api.models import StateChoices, User, Provider, Scan, Task, Resource, ResourceTag from api.rls import Tenant from api.utils import merge_dicts @@ -40,6 +41,68 @@ class StateEnumSerializerField(serializers.ChoiceField): super().__init__(**kwargs) +# Users + + +class UserSerializer(BaseSerializerV1): + """ + Serializer for the User model. + """ + + class Meta: + model = User + fields = [ + "id", + "username", + "email", + "date_joined", + ] + + +class UserCreateSerializer(BaseWriteSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ["username", "password", "email"] + + def validate_password(self, value): + user = User(**{k: v for k, v in self.initial_data.items() if k != "type"}) + validate_password(value, user=user) + return value + + def create(self, validated_data): + password = validated_data.pop("password") + user = User(**validated_data) + + validate_password(password, user=user) + user.set_password(password) + user.save() + return user + + +class UserUpdateSerializer(BaseWriteSerializer): + password = serializers.CharField(write_only=True, required=False) + + class Meta: + model = User + fields = ["id", "password", "email"] + extra_kwargs = { + "id": {"read_only": True}, + } + + def validate_password(self, value): + validate_password(value, user=self.instance) + return value + + def update(self, instance, validated_data): + password = validated_data.pop("password", None) + if password: + validate_password(password, user=instance) + instance.set_password(password) + return super().update(instance, validated_data) + + # Tasks class TaskBase(serializers.Serializer): state_mapping = { @@ -322,6 +385,7 @@ class ResourceSerializer(RLSSerializer): "type_", "tags", "provider", + "url", ] extra_kwargs = { "id": {"read_only": True}, diff --git a/src/backend/api/v1/urls.py b/src/backend/api/v1/urls.py index 36168b87e3..4318741bf7 100644 --- a/src/backend/api/v1/urls.py +++ b/src/backend/api/v1/urls.py @@ -4,6 +4,7 @@ from rest_framework import routers from api.v1.views import ( SchemaView, + UserViewSet, TenantViewSet, ProviderViewSet, ScanViewSet, @@ -13,6 +14,7 @@ from api.v1.views import ( router = routers.DefaultRouter(trailing_slash=False) +router.register(r"users", UserViewSet, basename="user") router.register(r"tenants", TenantViewSet, basename="tenant") router.register(r"providers", ProviderViewSet, basename="provider") router.register(r"scans", ScanViewSet, basename="scan") diff --git a/src/backend/api/v1/views.py b/src/backend/api/v1/views.py index 16c4b3f0d5..748b7d9d58 100644 --- a/src/backend/api/v1/views.py +++ b/src/backend/api/v1/views.py @@ -1,19 +1,19 @@ +from celery.result import AsyncResult from django.conf import settings as django_settings +from django.contrib.postgres.search import SearchQuery from django.db.models import F +from django.db.models import Q from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control -from django.contrib.postgres.search import SearchQuery -from django.db.models import Q - from drf_spectacular.settings import spectacular_settings from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.views import SpectacularAPIView -from rest_framework import status +from rest_framework import status, permissions from rest_framework.decorators import action +from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.generics import get_object_or_404 from rest_framework_json_api.views import Response -from celery.result import AsyncResult from api.base_views import BaseRLSViewSet, BaseViewSet from api.filters import ( @@ -23,9 +23,12 @@ from api.filters import ( TaskFilter, ResourceFilter, ) -from api.models import Provider, Scan, Task, Resource +from api.models import User, Provider, Scan, Task, Resource from api.rls import Tenant from api.v1.serializers import ( + UserSerializer, + UserCreateSerializer, + UserUpdateSerializer, ProviderSerializer, ProviderCreateSerializer, ProviderUpdateSerializer, @@ -58,6 +61,85 @@ class SchemaView(SpectacularAPIView): return super().get(request, *args, **kwargs) +@extend_schema_view( + create=extend_schema( + summary="Register a new user", + description="Create a new user account by providing the necessary registration details.", + ), + partial_update=extend_schema( + summary="Update the current user's information", + description="Partially update the authenticated user's information.", + ), + destroy=extend_schema( + summary="Delete the current user's account", + description="Remove the authenticated user's account from the system.", + ), + me=extend_schema( + summary="Retrieve the current user's information", + description="Fetch detailed information about the authenticated user.", + ), +) +@method_decorator(CACHE_DECORATOR, name="list") +class UserViewSet(BaseViewSet): + serializer_class = UserSerializer + http_method_names = ["get", "post", "patch", "delete"] + ordering = ["id"] + ordering_fields = [] + + def get_queryset(self): + return User.objects.filter(id=self.request.user.id) + + def get_permissions(self): + if self.action == "create": + permission_classes = [permissions.AllowAny] + else: + permission_classes = self.permission_classes + return [permission() for permission in permission_classes] + + def get_serializer_class(self): + if self.action == "create": + return UserCreateSerializer + elif self.action == "partial_update": + return UserUpdateSerializer + else: + return UserSerializer + + @extend_schema(exclude=True) + def list(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + @action(detail=False, methods=["get"], url_name="me") + def me(self, request): + user = self.get_queryset().first() + serializer = UserSerializer(user, context=self.get_serializer_context()) + return Response( + data=serializer.data, + status=status.HTTP_200_OK, + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer( + data=request.data, context=self.get_serializer_context() + ) + serializer.is_valid(raise_exception=True) + user = serializer.save() + return Response(data=UserSerializer(user).data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, *args, **kwargs): + if kwargs["pk"] != str(request.user.id): + raise NotFound(detail="User was not found.") + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + if kwargs["pk"] != str(request.user.id): + raise NotFound(detail="User was not found.") + return super().destroy(request, *args, **kwargs) + + @extend_schema_view( list=extend_schema( summary="List all tenants", diff --git a/src/backend/api/validators.py b/src/backend/api/validators.py new file mode 100644 index 0000000000..135a0fd6c0 --- /dev/null +++ b/src/backend/api/validators.py @@ -0,0 +1,22 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + + +class MaximumLengthValidator: + def __init__(self, max_length=72): + self.max_length = max_length + + def validate(self, password, user=None): + if len(password) > self.max_length: + raise ValidationError( + _( + "This password is too long. It must contain no more than %(max_length)d characters." + ), + code="password_too_long", + params={"max_length": self.max_length}, + ) + + def get_help_text(self): + return _( + f"Your password must contain no more than {self.max_length} characters." + ) diff --git a/src/backend/config/django/base.py b/src/backend/config/django/base.py index d2dc736239..1335019136 100644 --- a/src/backend/config/django/base.py +++ b/src/backend/config/django/base.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from config.custom_logging import LOGGING # noqa from config.env import BASE_DIR, env # noqa from config.settings.celery import * # noqa @@ -110,12 +112,21 @@ DATABASE_ROUTERS = ["api.db_router.MainRouter"] # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators +AUTH_USER_MODEL = "api.User" + AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 12}, + }, + { + "NAME": "api.validators.MaximumLengthValidator", + "OPTIONS": { + "max_length": 72, + }, }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", @@ -125,6 +136,20 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta( + minutes=env.int("DJANGO_ACCESS_TOKEN_LIFETIME", 30) + ), + "REFRESH_TOKEN_LIFETIME": timedelta( + minutes=env.int("DJANGO_REFRESH_TOKEN_LIFETIME", 60 * 24) + ), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": env.str("DJANGO_TOKEN_SIGNING_KEY", "S3cret"), + "AUTH_HEADER_TYPES": ("Bearer",), +} + # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ diff --git a/src/backend/conftest.py b/src/backend/conftest.py index f95394fe2f..fa2577992a 100644 --- a/src/backend/conftest.py +++ b/src/backend/conftest.py @@ -1,24 +1,35 @@ import logging - +import base64 import pytest from django.conf import settings -from django.db import connections as django_connections -from rest_framework import status +from django.db import connections as django_connections, connection as django_connection from django_celery_results.models import TaskResult -from api.models import Provider, Resource, ResourceTag, Scan, StateChoices, Task +from rest_framework.test import APIClient + +from rest_framework import status + +from api.models import User, Provider, Resource, ResourceTag, Scan, StateChoices, Task from api.rls import Tenant API_JSON_CONTENT_TYPE = "application/vnd.api+json" -# TODO Change to 401 when authentication/authorization is implemented -NO_TENANT_HTTP_STATUS = status.HTTP_403_FORBIDDEN +NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED +TEST_USER = "testing_user" +TEST_PASSWORD = "testing_psswd" +TEST_CREDENTIALS = f"{TEST_USER}:{TEST_PASSWORD}" +TEST_BASE64_CREDENTIALS = base64.b64encode(TEST_CREDENTIALS.encode()).decode() @pytest.fixture(scope="module") def enforce_test_user_db_connection(django_db_setup, django_db_blocker): """Ensure tests use the test user for database connections.""" + test_user = "test" + test_password = "test" + with django_db_blocker.unblock(): - test_user = "test" - test_password = "test" + with django_connection.cursor() as cursor: + # Required for testing purposes using APIClient + cursor.execute(f"GRANT ALL PRIVILEGES ON django_session TO {test_user};") + original_user = settings.DATABASES["default"]["USER"] original_password = settings.DATABASES["default"]["PASSWORD"] @@ -43,6 +54,28 @@ def disable_logging(): logging.disable(logging.CRITICAL) +@pytest.fixture(scope="session", autouse=True) +def create_test_user(django_db_setup, django_db_blocker): + with django_db_blocker.unblock(): + user = User.objects.create_user( + username=TEST_USER, password=TEST_PASSWORD, email="testing@gmail.com" + ) + return user + + +@pytest.fixture +def authenticated_client(create_test_user, client): + client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}" + return client + + +@pytest.fixture +def authenticated_api_client(create_test_user): + client = APIClient() + client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}" + return client + + @pytest.fixture def tenants_fixture(): tenant1 = Tenant.objects.create(