mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-16 16:34:24 +00:00
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:
1 parent
54bb034cac
commit
ded28baa2f
16 files changed
+836
-415
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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¶m2=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)
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user