feat(Scan, Resource, Finding): PRWLR-4015 Implement on demand scans and findings/resources in database (#48)

* 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

* chore(env): PRWLR-4718 update .env.example

* chore(Deps): PRWLR-4015 update prowler dep

* fix(Resources, Findings): PRWLR-4015 fix permission issues on models and migrations

* feat(Scans, Tasks, Resources, Findings): PRWLR-4015 perform whole flow when executing scans

* fix(Settings): PRWLR-4015 fix devel JWT settings

* chore(Scans, Tasks): PRWLR-4015 improve docs and responses format

* test(Scan, Provider): PRWLR-4015 fix unit tests

* chore(Environment): PRWLR-4015 fix .env.example values

* test(Scan): PRWLR-4015 add unit tests for scan task

* chore(Task): PRWLR-4015 give proper format to task result on scan perform

* feat(Provider, Scan): PRWLR-4015 add all provider types to connection check and scan

* fix(Logging): PRWLR-4015 fix API logger middleware to include tenant and user IDs
This commit is contained in:
Víctor Fernández Poyatos authored and GitHub committed 2024-10-03 18:36:42 +02:00
1 parent 54bb034cac
commit ded28baa2f
16 files changed
+836 -415

No files matched your search

+9 -1
View File
@@ -2,7 +2,7 @@ from contextlib import contextmanager
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import models
from django.db import models, transaction, connection
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import new_type, register_type, register_adapter, AsIs
@@ -40,6 +40,14 @@ def psycopg_connection(database_alias: str):
psycopg2_connection.close()
@contextmanager
def tenant_transaction(tenant_id: str):
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute(f"SELECT set_config('api.tenant_id', '{tenant_id}', TRUE);")
yield cursor
class CustomUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
+10 -14
View File
@@ -1,22 +1,16 @@
import logging
import time
from django.http import HttpRequest
from config.custom_logging import BackendLogger
def extract_tenant_id(request: HttpRequest) -> str | None:
"""
Extract the tenant ID from the request headers.
Args:
request (HttpRequest): The HTTP request object.
Returns:
str: The tenant ID if present in the headers, otherwise None.
"""
return request.headers.get("X-Tenant-ID")
def extract_auth_info(request) -> dict:
if not hasattr(request, "auth") or request.auth is not None:
tenant_id = request.auth.get("tenant_id", "N/A")
user_id = request.auth.get("user_id", "N/A")
else:
tenant_id, user_id = "N/A", "N/A"
return {"tenant_id": tenant_id, "user_id": user_id}
class APILoggingMiddleware:
@@ -38,15 +32,17 @@ class APILoggingMiddleware:
response = self.get_response(request)
duration = time.time() - request_start_time
auth_info = extract_auth_info(request)
self.logger.info(
"",
extra={
"user_id": auth_info["user_id"],
"tenant_id": auth_info["tenant_id"],
"method": request.method,
"path": request.path,
"query_params": request.GET.dict(),
"status_code": response.status_code,
"duration": duration,
"tenant_id": extract_tenant_id(request),
},
)
+81 -70
View File
@@ -420,6 +420,70 @@ class Migration(migrations.Migration):
name="unique_provider_uids",
),
),
migrations.CreateModel(
name="Task",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"task_runner_task",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="task",
related_query_name="task",
to="django_celery_results.taskresult",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "tasks",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="task",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_task",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddIndex(
model_name="task",
index=models.Index(
fields=["id", "task_runner_task"],
name="tasks_id_trt_id_idx",
),
),
migrations.RunSQL(
f"""
ALTER TABLE {TASK_RUNNER_DB_TABLE} ENABLE ROW LEVEL SECURITY;
CREATE POLICY "{DB_PROWLER_USER}_{TASK_RUNNER_DB_TABLE}_select"
ON {TASK_RUNNER_DB_TABLE}
FOR SELECT
TO {DB_PROWLER_USER}
USING (
task_id::uuid in (SELECT id FROM tasks WHERE tenant_id = (NULLIF(current_setting('{POSTGRES_TENANT_VAR}', true), ''))::uuid)
);
GRANT SELECT ON TABLE {TASK_RUNNER_DB_TABLE} TO {DB_PROWLER_USER};
"""
),
# Create and register ScanTriggerEnum type
migrations.RunPython(
ScanTriggerEnumMigration.create_enum_type,
@@ -488,6 +552,17 @@ class Migration(migrations.Migration):
to="api.provider",
),
),
(
"task",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scans",
related_query_name="scan",
to="api.task",
null=True,
blank=True,
),
),
(
"tenant",
models.ForeignKey(
@@ -515,70 +590,6 @@ class Migration(migrations.Migration):
name="scans_prov_state_trig_sche_idx",
),
),
migrations.CreateModel(
name="Task",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"task_runner_task",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="task",
related_query_name="task",
to="django_celery_results.taskresult",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "tasks",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="task",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_task",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddIndex(
model_name="task",
index=models.Index(
fields=["id", "task_runner_task"],
name="tasks_id_trt_id_idx",
),
),
migrations.RunSQL(
f"""
ALTER TABLE {TASK_RUNNER_DB_TABLE} ENABLE ROW LEVEL SECURITY;
CREATE POLICY "{DB_PROWLER_USER}_{TASK_RUNNER_DB_TABLE}_select"
ON {TASK_RUNNER_DB_TABLE}
FOR SELECT
TO {DB_PROWLER_USER}
USING (
task_id::uuid in (SELECT id FROM tasks WHERE tenant_id = (NULLIF(current_setting('{POSTGRES_TENANT_VAR}', true), ''))::uuid)
);
GRANT SELECT ON TABLE {TASK_RUNNER_DB_TABLE} TO {DB_PROWLER_USER};
"""
),
# Resources
migrations.RunSQL(
sql="""
@@ -812,7 +823,7 @@ class Migration(migrations.Migration):
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_resourcetag",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
@@ -827,13 +838,13 @@ class Migration(migrations.Migration):
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_resourcetagmapping",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="resource",
constraint=models.UniqueConstraint(
fields=("tenant_id", "provider_id", "uid"),
fields=("tenant_id", "provider_id", "uid", "region"),
name="unique_resources_by_provider",
),
),
@@ -842,7 +853,7 @@ class Migration(migrations.Migration):
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_resource",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
# Create and register ScanTypeEnum type
@@ -1084,7 +1095,7 @@ class Migration(migrations.Migration):
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_resourcefindingmapping",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
PostgresAddDefaultPartition(
@@ -1097,7 +1108,7 @@ class Migration(migrations.Migration):
"tenant_id",
name="rls_on_resource_finding_mappings_default",
partition_name="default",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AlterModelOptions(
+64 -55
View File
@@ -216,56 +216,6 @@ class Provider(RowLevelSecurityProtectedModel):
]
class Scan(RowLevelSecurityProtectedModel):
class TriggerChoices(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
MANUAL = "manual", _("Manual")
id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
name = models.CharField(
blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)]
)
provider = models.ForeignKey(
Provider,
on_delete=models.CASCADE,
related_name="scans",
related_query_name="scan",
)
trigger = ScanTriggerEnumField(
choices=TriggerChoices.choices,
)
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
unique_resource_count = models.IntegerField(default=0)
progress = models.IntegerField(default=0)
scanner_args = models.JSONField(default=dict)
duration = models.IntegerField(null=True, blank=True)
scheduled_at = models.DateTimeField(null=True, blank=True)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# TODO: task foreign key
# TODO: mutelist foreign key
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "scans"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
indexes = [
models.Index(
fields=["provider", "state", "trigger", "scheduled_at"],
name="scans_prov_state_trig_sche_idx",
),
]
class Task(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
@@ -297,6 +247,63 @@ class Task(RowLevelSecurityProtectedModel):
]
class Scan(RowLevelSecurityProtectedModel):
class TriggerChoices(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
MANUAL = "manual", _("Manual")
id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
name = models.CharField(
blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)]
)
provider = models.ForeignKey(
Provider,
on_delete=models.CASCADE,
related_name="scans",
related_query_name="scan",
)
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="scans",
related_query_name="scan",
null=True,
blank=True,
)
trigger = ScanTriggerEnumField(
choices=TriggerChoices.choices,
)
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
unique_resource_count = models.IntegerField(default=0)
progress = models.IntegerField(default=0)
scanner_args = models.JSONField(default=dict)
duration = models.IntegerField(null=True, blank=True)
scheduled_at = models.DateTimeField(null=True, blank=True)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# TODO: mutelist foreign key
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "scans"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
indexes = [
models.Index(
fields=["provider", "state", "trigger", "scheduled_at"],
name="scans_prov_state_trig_sche_idx",
),
]
class ResourceTag(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
@@ -329,7 +336,7 @@ class ResourceTag(RowLevelSecurityProtectedModel):
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
@@ -409,13 +416,13 @@ class Resource(RowLevelSecurityProtectedModel):
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "provider_id", "uid"),
fields=("tenant_id", "provider_id", "uid", "region"),
name="unique_resources_by_provider",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
@@ -444,7 +451,7 @@ class ResourceTagMapping(RowLevelSecurityProtectedModel):
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
@@ -485,9 +492,11 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
impact_extended = models.TextField(blank=True, null=True)
raw_result = models.JSONField(default=dict)
# TODO: review usability
tags = models.JSONField(default=dict, null=True, blank=True)
check_id = models.CharField(max_length=100, blank=False, null=False)
# TODO: review usability
check_metadata = models.JSONField(default=dict, null=False)
# Relationships
@@ -591,6 +600,6 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT"],
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
+56 -80
View File
@@ -651,7 +651,7 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/SerializerMetaclassResponse'
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/providers/{id}/connection:
post:
@@ -676,7 +676,7 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/SerializerMetaclassResponse'
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/resources:
get:
@@ -1013,6 +1013,7 @@ paths:
- scanner_args
- duration
- provider
- task
- started_at
- completed_at
- scheduled_at
@@ -1181,11 +1182,11 @@ paths:
security:
- jwtAuth: []
responses:
'201':
'202':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ScanCreateResponse'
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/scans/{id}:
get:
@@ -1208,6 +1209,7 @@ paths:
- scanner_args
- duration
- provider
- task
- started_at
- completed_at
- scheduled_at
@@ -1267,7 +1269,7 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/SerializerMetaclassResponse'
$ref: '#/components/schemas/ScanUpdateResponse'
description: ''
/api/v1/tasks:
get:
@@ -1415,7 +1417,7 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/SerializerMetaclassResponse'
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/tenants:
get:
@@ -1781,7 +1783,8 @@ paths:
/api/v1/tokens/refresh:
post:
operationId: tokens_refresh_create
description: Refresh an access token by providing a valid refresh token.
description: Refresh an access token by providing a valid refresh token. Former
refresh tokens are invalidated when a new one is issued.
summary: Refresh a token
tags:
- Token
@@ -2111,35 +2114,6 @@ paths:
description: ''
components:
schemas:
DelayedTask:
type: object
required:
- type
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/TypeB52Enum'
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:
id:
type: string
state:
type: string
enum:
- available
- scheduled
- executing
- completed
- failed
- cancelled
readOnly: true
required:
- id
Finding:
type: object
required:
@@ -2376,6 +2350,13 @@ components:
type: string
enum:
- Membership
OpenApiResponseResponse:
type: object
properties:
data:
$ref: '#/components/schemas/Task'
required:
- data
PaginatedFindingList:
type: object
properties:
@@ -2988,33 +2969,7 @@ components:
- data
description: The identifier of the related object.
title: Resource Identifier
required:
- provider
ScanCreate:
type: object
required:
- type
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/Type2bbEnum'
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:
scanner_args: {}
name:
type: string
nullable: true
maxLength: 100
minLength: 3
relationships:
type: object
properties:
provider:
task:
type: object
properties:
data:
@@ -3026,7 +2981,7 @@ components:
type:
type: string
enum:
- Provider
- Task
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
@@ -3038,6 +2993,7 @@ components:
- data
description: The identifier of the related object.
title: Resource Identifier
nullable: true
required:
- provider
ScanCreateRequest:
@@ -3096,13 +3052,6 @@ components:
- provider
required:
- data
ScanCreateResponse:
type: object
properties:
data:
$ref: '#/components/schemas/ScanCreate'
required:
- data
ScanResponse:
type: object
properties:
@@ -3110,6 +3059,37 @@ components:
$ref: '#/components/schemas/Scan'
required:
- data
ScanUpdate:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/Type2bbEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
attributes:
type: object
properties:
name:
type: string
nullable: true
maxLength: 100
minLength: 3
ScanUpdateResponse:
type: object
properties:
data:
$ref: '#/components/schemas/ScanUpdate'
required:
- data
SerializerMetaclassResponse:
type: object
properties:
@@ -3121,18 +3101,14 @@ components:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/TypeB52Enum'
- $ref: '#/components/schemas/TaskTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
attributes:
type: object
properties:
@@ -3170,6 +3146,10 @@ components:
$ref: '#/components/schemas/Task'
required:
- data
TaskTypeEnum:
type: string
enum:
- Task
Tenant:
type: object
required:
@@ -3466,10 +3446,6 @@ components:
type: string
enum:
- User
TypeB52Enum:
type: string
enum:
- Task
User:
type: object
required:
+45 -24
View File
@@ -1,33 +1,54 @@
from unittest.mock import patch, Mock, ANY
from unittest.mock import MagicMock, patch
import pytest
from django.http import HttpResponse
from django.test import RequestFactory
from api.middleware import APILoggingMiddleware
@pytest.mark.django_db
@patch("logging.getLogger")
def test_api_logger_middleware(mock_get_logger, client):
mock_logger = Mock()
mock_get_logger.side_effect = lambda name: mock_logger if name == "api" else Mock()
request_method = "GET"
request_path = "/this_path_does_not_exist"
tenant_id = "12646005-9067-4d2a-a098-8bb378604362"
api_logger = "api"
def test_api_logging_middleware_logging(mock_logger):
factory = RequestFactory()
response = getattr(client, request_method.lower())(
request_path, headers={"X-Tenant-ID": tenant_id}
)
request = factory.get("/test-path?param1=value1&param2=value2")
request.method = "GET"
mock_get_logger.assert_any_call(api_logger)
mock_logger.info.assert_called_once_with(
"",
extra={
"method": request_method,
"path": request_path,
"query_params": {},
"status_code": response.status_code,
"duration": ANY,
"tenant_id": tenant_id,
},
)
response = HttpResponse()
response.status_code = 200
assert isinstance(mock_logger.info.call_args[1]["extra"]["duration"], float)
get_response = MagicMock(return_value=response)
with patch("api.middleware.extract_auth_info") as mock_extract_auth_info:
mock_extract_auth_info.return_value = {
"user_id": "user123",
"tenant_id": "tenant456",
}
with patch("api.middleware.logging.getLogger") as mock_get_logger:
mock_logger = MagicMock()
mock_get_logger.return_value = mock_logger
middleware = APILoggingMiddleware(get_response)
with patch("api.middleware.time.time") as mock_time:
mock_time.side_effect = [1000.0, 1001.0] # Start time and end time
middleware(request)
get_response.assert_called_once_with(request)
mock_extract_auth_info.assert_called_once_with(request)
expected_extra = {
"user_id": "user123",
"tenant_id": "tenant456",
"method": "GET",
"path": "/test-path",
"query_params": {"param1": "value1", "param2": "value2"},
"status_code": 200,
"duration": 1.0,
}
mock_logger.info.assert_called_once_with("", extra=expected_extra)
+24 -3
View File
@@ -926,13 +926,21 @@ class TestProviderViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_providers_delete(
self, mock_delete_task, authenticated_client, providers_fixture
self,
mock_delete_task,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
task_mock = Mock()
task_mock.id = "12345"
task_mock.id = prowler_task.id
mock_delete_task.return_value = task_mock
mock_task_get.return_value = prowler_task
provider1, *_ = providers_fixture
response = authenticated_client.delete(
@@ -951,17 +959,22 @@ class TestProviderViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_providers_connection(
self,
mock_provider_connection,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
task_mock = Mock()
task_mock.id = "12345"
task_mock.id = prowler_task.id
task_mock.status = "PENDING"
mock_provider_connection.return_value = task_mock
mock_task_get.return_value = prowler_task
provider1, *_ = providers_fixture
assert provider1.connected is None
@@ -1127,13 +1140,21 @@ class TestScanViewSet:
),
],
)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.perform_scan_task.delay")
def test_scans_create_valid(
self,
mock_perform_scan_task,
mock_task_get,
authenticated_client,
scan_json_payload,
expected_scanner_args,
providers_fixture,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_perform_scan_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
*_, provider5 = providers_fixture
# Provider5 has these scanner_args
# scanner_args={"key1": "value1", "key2": {"key21": "value21"}}
+31 -27
View File
@@ -86,7 +86,7 @@ class TokenSerializer(serializers.Serializer):
except Exception as e:
raise serializers.ValidationError({"detail": str(e)})
return {"refresh": str(refresh), "access": str(access)}
return {"access": str(access), "refresh": str(refresh)}
class TokenRefreshSerializer(serializers.Serializer):
@@ -215,7 +215,7 @@ class UserUpdateSerializer(BaseWriteSerializer):
# Tasks
class TaskBase(serializers.Serializer):
class TaskBase(serializers.ModelSerializer):
state_mapping = {
"PENDING": StateChoices.SCHEDULED,
"STARTED": StateChoices.EXECUTING,
@@ -225,8 +225,9 @@ class TaskBase(serializers.Serializer):
"REVOKED": StateChoices.CANCELLED,
}
class JSONAPIMeta:
resource_name = "Task"
class Meta:
fields = ["id"]
model = Task
def map_state(self, task_result_state):
return self.state_mapping.get(task_result_state, StateChoices.AVAILABLE)
@@ -244,27 +245,6 @@ class TaskBase(serializers.Serializer):
return self.map_state(task_result_state)
class DelayedTaskSerializer(TaskBase):
id = serializers.CharField()
state = serializers.SerializerMethodField(read_only=True)
class Meta:
fields = [
"id",
"state",
]
@extend_schema_field(
{
"type": "string",
"enum": StateChoices.values,
}
)
def get_state(self, obj):
task_result_state = obj.status if obj else None
return self.map_state(task_result_state)
class TaskSerializer(RLSSerializer, TaskBase):
state = serializers.SerializerMethodField(read_only=True)
metadata = serializers.SerializerMethodField(read_only=True)
@@ -444,6 +424,7 @@ class ScanSerializer(RLSSerializer):
"scanner_args",
"duration",
"provider",
"task",
"started_at",
"completed_at",
"scheduled_at",
@@ -455,7 +436,7 @@ class ScanCreateSerializer(RLSSerializer, BaseWriteSerializer):
class Meta:
model = Scan
# TODO: add mutelist when implemented
fields = ["provider", "scanner_args", "name"]
fields = ["id", "provider", "scanner_args", "name"]
def create(self, validated_data):
provider = validated_data.get("provider")
@@ -488,9 +469,32 @@ class ScanUpdateSerializer(BaseWriteSerializer):
}
class ScanTaskSerializer(RLSSerializer):
trigger = serializers.ChoiceField(
choices=Scan.TriggerChoices.choices, read_only=True
)
state = StateEnumSerializerField(read_only=True)
class Meta:
model = Scan
fields = [
"id",
"name",
"trigger",
"state",
"unique_resource_count",
"progress",
"scanner_args",
"duration",
"started_at",
"completed_at",
"scheduled_at",
]
class ResourceTagSerializer(RLSSerializer):
"""
Serializer fore the ResourceTag model
Serializer for the ResourceTag model
"""
class Meta:
+64 -32
View File
@@ -7,7 +7,12 @@ 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 OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiResponse,
)
from drf_spectacular.views import SpectacularAPIView
from rest_framework import status, permissions
from rest_framework.decorators import action
@@ -42,14 +47,17 @@ from api.v1.serializers import (
ProviderUpdateSerializer,
TenantSerializer,
TaskSerializer,
DelayedTaskSerializer,
ScanSerializer,
ScanCreateSerializer,
ScanUpdateSerializer,
ResourceSerializer,
FindingSerializer,
)
from tasks.tasks import check_provider_connection_task, delete_provider_task
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_task,
)
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
@@ -390,7 +398,7 @@ class TenantMembersViewSet(BaseTenantViewset):
tags=["Provider"],
summary="Delete a provider",
description="Remove a provider from the system by their ID.",
responses={202: DelayedTaskSerializer},
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@@ -420,7 +428,7 @@ class ProviderViewSet(BaseRLSViewSet):
elif self.action == "partial_update":
return ProviderUpdateSerializer
elif self.action in ["connection", "destroy"]:
return DelayedTaskSerializer
return TaskSerializer
return super().get_serializer_class()
def partial_update(self, request, *args, **kwargs):
@@ -443,7 +451,7 @@ class ProviderViewSet(BaseRLSViewSet):
summary="Check connection",
description="Try to verify connection. For instance, Role & Credentials are set correctly",
request=None,
responses={202: DelayedTaskSerializer},
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
@@ -451,24 +459,30 @@ class ProviderViewSet(BaseRLSViewSet):
task = check_provider_connection_task.delay(
provider_id=pk, tenant_id=request.tenant_id
)
serializer = DelayedTaskSerializer(task)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse("task-detail", kwargs={"pk": task.id})
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
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.tenant_id)
serializer = DelayedTaskSerializer(task)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse("task-detail", kwargs={"pk": task.id})
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
@@ -482,6 +496,10 @@ class ProviderViewSet(BaseRLSViewSet):
summary="Retrieve data from a specific scan",
description="Fetch detailed information about a specific scan by its ID.",
),
partial_update=extend_schema(
summary="Partially update a scan",
description="Update certain fields of an existing scan without affecting other fields.",
),
create=extend_schema(
summary="Trigger a manual scan",
description=(
@@ -491,12 +509,8 @@ class ProviderViewSet(BaseRLSViewSet):
"merged with the provider's defaults. This means that your provided settings will override "
"the defaults only where they conflict, while the rest of the default settings will remain intact."
),
),
partial_update=extend_schema(
summary="Partially update a scan",
description="Update certain fields of an existing scan without affecting other fields.",
request=ScanUpdateSerializer,
responses={200: ScanSerializer},
request=ScanCreateSerializer,
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@@ -521,25 +535,13 @@ class ScanViewSet(BaseRLSViewSet):
def get_serializer_class(self):
if self.action == "create":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScanCreateSerializer
elif self.action == "partial_update":
return ScanUpdateSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
scan = serializer.save()
# TODO: Run scan through task and return task info here
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse("scan-detail", kwargs={"pk": scan.id})
},
)
def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(
@@ -555,6 +557,35 @@ class ScanViewSet(BaseRLSViewSet):
)
return Response(data=read_serializer.data, status=status.HTTP_200_OK)
def create(self, request, *args, **kwargs):
input_serializer = self.get_serializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
scan = input_serializer.save()
task = perform_scan_task.delay(
tenant_id=request.tenant_id,
scan_id=str(scan.id),
provider_id=str(scan.provider_id),
checks_to_execute=scan.scanner_args.get("checks_to_execute", []),
)
scan.task_id = task.id
scan.save(update_fields=["task_id"])
prowler_task = Task.objects.get(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
return Response(
data=output_serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
@extend_schema_view(
list=extend_schema(
@@ -569,7 +600,7 @@ class ScanViewSet(BaseRLSViewSet):
tags=["Task"],
summary="Revoke a task",
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
responses={202: DelayedTaskSerializer},
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
class TaskViewSet(BaseRLSViewSet):
@@ -602,7 +633,8 @@ class TaskViewSet(BaseRLSViewSet):
task_instance = AsyncResult(pk)
task_instance.revoke()
serializer = DelayedTaskSerializer(task_instance)
task.refresh_from_db()
serializer = TaskSerializer(task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
+8 -4
View File
@@ -45,6 +45,10 @@ class NDJSONFormatter(logging.Formatter):
}
# Add REST API extra fields
if hasattr(record, "user_id"):
log_record["user_id"] = record.user_id
if hasattr(record, "tenant_id"):
log_record["tenant_id"] = record.tenant_id
if hasattr(record, "method"):
log_record["method"] = record.method
if hasattr(record, "path"):
@@ -55,8 +59,6 @@ class NDJSONFormatter(logging.Formatter):
log_record["duration"] = record.duration
if hasattr(record, "status_code"):
log_record["status_code"] = record.status_code
if hasattr(record, "tenant_id"):
log_record["tenant_id"] = record.tenant_id
if record.exc_info:
log_record["exc_info"] = self.formatException(record.exc_info)
@@ -82,13 +84,15 @@ class HumanReadableFormatter(logging.Formatter):
f"function={record.funcName}",
f"process={record.process}",
f"thread={record.thread}",
f"transaction-id={record.transaction_id if hasattr(record, "transaction_id") else None}]",
f"transaction-id={record.transaction_id if hasattr(record, 'transaction_id') else None}]",
f"{record.getMessage()}",
]
# Add REST API extra fields
if hasattr(record, "user_id"):
log_components.append(f"({record.user_id})")
if hasattr(record, "tenant_id"):
log_components.append(f"({record.tenant_id})")
log_components.append(f"[{record.tenant_id}]")
if hasattr(record, "method"):
log_components.append(f'"{record.method} {record.path}"')
if hasattr(record, "query_params"):
+18 -9
View File
@@ -2,6 +2,9 @@ from datetime import datetime, timezone
from celery.utils.log import get_task_logger
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from api.models import Provider
@@ -26,20 +29,26 @@ def check_provider_connection(provider_id: str):
"""
provider_instance = Provider.objects.get(pk=provider_id)
match provider_instance.provider:
# TODO Refactor when proper credentials are implemented
case Provider.ProviderChoices.AWS.value:
# TODO Refactor when proper credentials are implemented
try:
connection_result = AwsProvider.test_connection(
raise_on_exception=False
)
# TODO: Improve this exception handling when SDK exceptions are implemented
except Exception as e:
logger.warning(str(e))
raise e
prowler_provider = AwsProvider
case Provider.ProviderChoices.GCP.value:
prowler_provider = GcpProvider
case Provider.ProviderChoices.AZURE.value:
prowler_provider = AzureProvider
case Provider.ProviderChoices.KUBERNETES.value:
prowler_provider = KubernetesProvider
case _:
raise ValueError(
f"Provider type {provider_instance.provider} not supported"
)
try:
connection_result = prowler_provider.test_connection(raise_on_exception=False)
except Exception as e:
logger.warning(
f"Unexpected exception checking {provider_instance.provider} provider connection: {str(e)}"
)
raise e
provider_instance.connected = connection_result.is_connected
provider_instance.connection_last_checked_at = datetime.now(tz=timezone.utc)
+146
View File
@@ -0,0 +1,146 @@
import json
import time
from datetime import datetime, timezone
from celery.utils.log import get_task_logger
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from api.db_utils import tenant_transaction
from api.models import (
Provider,
Scan,
Finding,
Resource,
ResourceTag,
StatusChoices as FindingStatus,
StateChoices,
)
from api.v1.serializers import ScanTaskSerializer
from config.celery import TaskTimeoutError
logger = get_task_logger(__name__)
def store_finding(
finding: ProwlerFinding,
tenant_id: str,
scan_instance: Scan,
resource_instance: Resource,
) -> Finding:
with tenant_transaction(tenant_id):
finding_instance = Finding.objects.create(
tenant_id=tenant_id,
delta=Finding.DeltaChoices.NEW,
status=FindingStatus[finding.status.value],
status_extended=finding.status_extended,
severity=finding.severity.value,
impact=finding.severity.value,
raw_result=json.loads(finding.json()),
check_id=finding.check_id,
scan=scan_instance,
)
finding_instance.add_resources([resource_instance])
return finding_instance
def store_resources(
finding: ProwlerFinding, tenant_id: str, provider_instance: Provider
) -> tuple[Resource, tuple[str, str]]:
with tenant_transaction(tenant_id):
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
uid=finding.resource_uid,
region=finding.region,
service=finding.service_name,
type=finding.resource_type,
)
with tenant_transaction(tenant_id):
tags = [
ResourceTag.objects.get_or_create(
tenant_id=tenant_id, key=key, value=value
)[0]
for key, value in finding.resource_tags.items()
]
resource_instance.upsert_or_delete_tags(tags=tags)
return resource_instance, (resource_instance.uid, resource_instance.region)
def perform_prowler_scan(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
):
with tenant_transaction(tenant_id):
exception = None
provider_instance = Provider.objects.get(pk=provider_id)
start_time = time.time()
unique_resources = set()
# Prevent race conditions
while not Scan.objects.filter(id=scan_id).exists():
if time.time() - start_time > 10:
raise TaskTimeoutError(
f"Could not find scan with given id {scan_id} within 10 seconds"
)
time.sleep(0.1)
scan_instance = Scan.objects.get(pk=scan_id)
scan_instance.state = StateChoices.EXECUTING
scan_instance.started_at = datetime.now(tz=timezone.utc)
scan_instance.save()
try:
match provider_instance.provider:
case Provider.ProviderChoices.AWS.value:
prowler_provider = AwsProvider
case Provider.ProviderChoices.GCP.value:
prowler_provider = GcpProvider
case Provider.ProviderChoices.AZURE.value:
prowler_provider = AzureProvider
case Provider.ProviderChoices.KUBERNETES.value:
prowler_provider = KubernetesProvider
case _:
raise ValueError(
f"Provider type {provider_instance.provider} not supported"
)
with tenant_transaction(tenant_id):
connection_status = prowler_provider.test_connection(
raise_on_exception=False
)
provider_instance.connected = connection_status.is_connected
provider_instance.connection_last_checked_at = datetime.now(tz=timezone.utc)
provider_instance.save()
if connection_status.is_connected is False:
raise ValueError(f"Provider {provider_instance.provider} is not connected")
prowler_scan = ProwlerScan(
provider=prowler_provider(), checks_to_execute=checks_to_execute or []
)
for progress, findings in prowler_scan.scan():
for finding in findings:
resource_instance, resource_uid_tuple = store_resources(
finding, tenant_id, provider_instance
)
store_finding(finding, tenant_id, scan_instance, resource_instance)
unique_resources.add(resource_uid_tuple)
with tenant_transaction(tenant_id):
scan_instance.progress = progress
scan_instance.save()
scan_instance.state = StateChoices.COMPLETED
except Exception as e:
logger.error(f"Error performing scan {scan_id}: {e}")
exception = e
scan_instance.state = StateChoices.FAILED
finally:
with tenant_transaction(tenant_id):
scan_instance.duration = time.time() - start_time
scan_instance.completed_at = datetime.now(tz=timezone.utc)
scan_instance.unique_resource_count = len(unique_resources)
scan_instance.save()
if exception is not None:
raise exception
serializer = ScanTaskSerializer(instance=scan_instance)
return serializer.data
+29
View File
@@ -5,6 +5,7 @@ from api.models import Provider
from config.celery import RLSTask
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_instance
from tasks.jobs.scan import perform_prowler_scan
@shared_task(base=RLSTask, name="provider-connection-check")
@@ -40,3 +41,31 @@ def delete_provider_task(provider_id: str):
including related models if cascading deletes were triggered.
"""
return delete_instance(model=Provider, pk=provider_id)
@shared_task(base=RLSTask, name="scan-perform")
def perform_scan_task(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
):
"""
Task to perform a Prowler scan on a given provider.
This task runs a Prowler scan on the provider identified by `provider_id`
under the tenant identified by `tenant_id`. The scan will use the `scan_id`
for tracking purposes.
Args:
tenant_id (str): The tenant ID under which the scan is being performed.
scan_id (str): The ID of the scan to be performed.
provider_id (str): The primary key of the Provider instance to scan.
checks_to_execute (list[str], optional): A list of specific checks to perform during the scan. Defaults to None.
Returns:
dict: The result of the scan execution, typically including the status and results of the performed checks.
"""
return perform_prowler_scan(
tenant_id=tenant_id,
scan_id=scan_id,
provider_id=provider_id,
checks_to_execute=checks_to_execute,
)
+111
View File
@@ -0,0 +1,111 @@
import json
from unittest.mock import patch, MagicMock, PropertyMock
import pytest
from api.models import StateChoices, StatusChoices, Severity, Finding, Resource
from tasks.jobs.scan import perform_prowler_scan
@pytest.mark.django_db
class TestPerformScan:
@patch("tasks.jobs.scan.ProwlerScan")
@patch("tasks.jobs.scan.AwsProvider")
@patch("api.db_utils.tenant_transaction")
def test_perform_prowler_scan_success(
self,
mock_tenant_transaction,
mock_aws_provider,
mock_prowler_scan,
tenants_fixture,
scans_fixture,
providers_fixture,
):
assert len(Finding.objects.all()) == 0
assert len(Resource.objects.all()) == 0
tenant, _ = tenants_fixture
scan, *_ = scans_fixture
provider, *_ = providers_fixture
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
checks_to_execute = ["check1", "check2"]
connection_status_mock = MagicMock()
type(connection_status_mock).is_connected = PropertyMock(return_value=True)
mock_aws_provider.test_connection.return_value = connection_status_mock
mock_aws_provider_instance = mock_aws_provider.return_value
mock_aws_provider_instance.test_connection.return_value = connection_status_mock
finding = MagicMock()
finding.status = StatusChoices.PASS
finding.status_extended = "test status extended"
finding.severity = Severity.medium
finding.check_id = "check1"
finding.json.return_value = '{"key": "value"}'
finding.resource_uid = "resource_uid"
finding.region = "region"
finding.service_name = "service_name"
finding.resource_type = "resource_type"
mock_prowler_scan.return_value.scan.return_value = [(100, [finding])]
perform_prowler_scan(tenant_id, scan_id, provider_id, checks_to_execute)
scan.refresh_from_db()
scan_finding = Finding.objects.get(scan=scan)
scan_resource = Resource.objects.get(provider=provider)
assert scan.tenant == tenant
assert scan.provider == provider
assert scan.state == StateChoices.COMPLETED
assert scan.completed_at is not None
assert scan.duration is not None
assert scan.started_at is not None
assert scan.unique_resource_count == 1
assert scan.progress == 100
assert scan_finding.status == finding.status
assert scan_finding.status_extended == finding.status_extended
assert scan_finding.severity == finding.severity
assert scan_finding.check_id == finding.check_id
assert scan_finding.raw_result == json.loads(finding.json())
assert scan_resource.tenant == tenant
assert scan_resource.uid == finding.resource_uid
assert scan_resource.region == finding.region
assert scan_resource.service == finding.service_name
assert scan_resource.type == finding.resource_type
@patch("tasks.jobs.scan.ProwlerScan")
@patch("tasks.jobs.scan.AwsProvider")
@patch("api.db_utils.tenant_transaction")
def test_perform_prowler_scan_no_connection(
self,
mock_tenant_transaction,
mock_aws_provider,
mock_prowler_scan,
tenants_fixture,
scans_fixture,
providers_fixture,
):
tenant, _ = tenants_fixture
scan, *_ = scans_fixture
provider, *_ = providers_fixture
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
checks_to_execute = ["check1", "check2"]
connection_status_mock = MagicMock()
type(connection_status_mock).is_connected = PropertyMock(return_value=False)
mock_aws_provider.test_connection.return_value = connection_status_mock
mock_aws_provider_instance = mock_aws_provider.return_value
mock_aws_provider_instance.test_connection.return_value = connection_status_mock
with pytest.raises(ValueError):
perform_prowler_scan(tenant_id, scan_id, provider_id, checks_to_execute)
scan.refresh_from_db()
assert scan.state == StateChoices.FAILED