mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(Users): #PRWLR-4717 add /users endpoints and basic auth (#43)
* feat(SimpleJWT): PRWLR-4717 add djangorestframework-simplejwt dep * feat(SimpleJWT): PRWLR-4717 add basic configuration and env variables * feat(Users): PRWLR-4717 add model and security constraints * feat(Users): PRWLR-4717 add serializers * feat(Users): PRWLR-4717 add views * test(Conftest): PRWLR-4717 add user and authenticated client fixtures * fix(Unit tests): PRWLR-4717 add automated authentication to all unit tests * fix(Authentication): PRWLR-4717 add authentication class and update tests * test(Users): PRWLR-4717 add unit tests * test(Users): PRWLR-4717 add integration tests * chore(Schema): PRWLR-4717 update API schema * fix(User): PRWLR-4717 fix password validation * feat(Validators): PRWLR-4717 add MaxLength password validator * fix(User): PRWLR-4717 update User model to delete admin fields * chore(Serializers): PRWLR-4717 add docstrings and update serializers * chore(Fixtures): PRWLR-4717 add dev user * chore(Users): PRWLR-4717 raise DRF NotFound instead of returning response
This commit is contained in:
committed by
GitHub
parent
6a341b88f0
commit
9ffde34198
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+25
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+740
-75
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
+442
-133
File diff suppressed because it is too large
Load Diff
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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/
|
||||
|
||||
|
||||
+41
-8
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user