mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
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:
committed by
GitHub
parent
4c83351b26
commit
54bb034cac
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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};
|
||||
"""
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,8 @@ DATABASES = {
|
||||
|
||||
DATABASE_ROUTERS = []
|
||||
TESTING = True
|
||||
|
||||
|
||||
# JWT
|
||||
|
||||
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
|
||||
|
||||
+44
-13
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user