feat(Tokens): PRWLR-4718 implement user authorization with JWT (#47)

* feat(Users): PRWLR-4718 make user email the default login username

* feat(Token): PRWLR-4718 add serializers, views and urls for access and refresh tokens

* feat(Token): PRWLR-4718 add first membership tenant in token if not present in json body

* feat(Users): PRWLR-4718 add company_name to model

* feat(Users): PRWLR-4718 create tenant and membership when creating new user

* fix(BaseView): PRWLR-4718 add tenant_id to serializer context

* fix(Tests): PRWLR-4718 use authorization with unit tests

* fix(Views): PRWLR-4718 fix tenant retrieval from request

* fix(Tests): PRWLR-4718 fix tests

* fix(Fixtures): PRWLR-4718 fix tenant memberships ordering

* chore(Tokens): PRWLR-4718 update token url

* chore(Spec): PRWLR-4718 update API spec

* feat(Tokens): PRWLR-4718 enable token refresh blacklisting

* feat(Tokens): PRWLR-4718 implement RS256 algorithm and dev valid keys

* fix(Environment): PRWLR-4718 fix jwt keys env vars

* fix(Environment): PRWLR-4718 fix jwt keys env vars (testing)

* chore(Settings): PRWLR-4718 remove drf-spectacular unused settings

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